├── src ├── assets │ └── .gitkeep ├── favicon.ico ├── styles.css ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── app │ ├── modules │ │ └── angular-ab-tests │ │ │ ├── error.ts │ │ │ ├── injection-tokens.ts │ │ │ ├── server │ │ │ ├── ab-tests-server.module.ts │ │ │ ├── server-crawler-detector.service.ts │ │ │ └── server-cookie-handler.service.ts │ │ │ ├── module.ts │ │ │ ├── directive.ts │ │ │ ├── classes.ts │ │ │ └── service.ts │ ├── shared.module.ts │ ├── tests.module.ts │ ├── core.module.ts │ ├── app.module.ts │ ├── old.component.ts │ ├── new.component.ts │ ├── app.component.ts │ └── directive.spec.ts ├── tsconfig.app.json ├── index.html ├── tsconfig.spec.json ├── tslint.json ├── browserslist ├── main.ts ├── test.ts ├── karma.conf.js └── polyfills.ts ├── ng-package.json ├── e2e ├── src │ ├── app.po.ts │ └── app.e2e-spec.ts ├── tsconfig.e2e.json └── protractor.conf.js ├── .editorconfig ├── tsconfig.json ├── public_api.ts ├── .gitignore ├── MIT-LICENSE ├── package.json ├── tslint.json ├── angular.json └── README.md /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrdilauro/angular-ab-tests/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/modules/angular-ab-tests/error.ts: -------------------------------------------------------------------------------- 1 | export function error(msg: string) { 2 | throw ('AngularAbTests error: ' + msg); 3 | } 4 | -------------------------------------------------------------------------------- /src/app/modules/angular-ab-tests/injection-tokens.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | import { AbTestOptions } from './module'; 3 | 4 | export const CONFIG = new InjectionToken('ANGULAR_AB_TEST_CONFIG'); 5 | -------------------------------------------------------------------------------- /ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/ng-packagr/ng-package.schema.json", 3 | "lib": { 4 | "entryFile": "public_api.ts" 5 | }, 6 | "whitelistedNonPeerDependencies": ["angular", "core-js", "rxjs", "zone.js", "nguniversal", "ngx-utils"] 7 | } 8 | -------------------------------------------------------------------------------- /src/app/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { AbTestsModule } from './modules/angular-ab-tests/module'; 3 | 4 | @NgModule({ 5 | imports: [ AbTestsModule ], 6 | exports: [ AbTestsModule ], 7 | }) 8 | export class SharedModule {} 9 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "es2015", 6 | "types": [] 7 | }, 8 | "exclude": [ 9 | "src/test.ts", 10 | "**/*.spec.ts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AngularAbTests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('workspace-project App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should display welcome message', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toEqual('Welcome to app!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "module": "commonjs", 6 | "types": [ 7 | "jasmine", 8 | "node" 9 | ] 10 | }, 11 | "files": [ 12 | "test.ts", 13 | "polyfills.ts" 14 | ], 15 | "include": [ 16 | "**/*.spec.ts", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "app", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "app", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/browserslist: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # For IE 9-11 support, please uncomment the last line of the file and adjust as needed 5 | > 0.5% 6 | last 2 versions 7 | Firefox ESR 8 | not dead 9 | # IE 9-11 -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.log(err)); 13 | -------------------------------------------------------------------------------- /src/app/tests.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { AbTestsModule, AbTestOptions } from './modules/angular-ab-tests/module'; 3 | 4 | export const abTestsOptions: AbTestOptions[] = [ 5 | { 6 | versions: [ 'old', 'new' ], 7 | versionForCrawlers: 'old', 8 | weights: { new: 60 } 9 | }, 10 | ]; 11 | 12 | @NgModule({ 13 | imports: [ 14 | AbTestsModule.forRoot(abTestsOptions), 15 | ], 16 | }) 17 | export class TestsModule {} 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "moduleResolution": "node", 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "target": "es5", 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ], 15 | "lib": [ 16 | "es2017", 17 | "dom" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /public_api.ts: -------------------------------------------------------------------------------- 1 | 2 | export {AbTestsModule, AbTestOptions} from './src/app/modules/angular-ab-tests/module'; 3 | export {AbTestsServerModule} from './src/app/modules/angular-ab-tests/server/ab-tests-server.module'; 4 | 5 | export {AbTestsService} from './src/app/modules/angular-ab-tests/service'; 6 | export {CrawlerDetector, RandomExtractor, CookieHandler} from './src/app/modules/angular-ab-tests/classes'; 7 | 8 | export * from './src/app/modules/angular-ab-tests/injection-tokens'; 9 | 10 | -------------------------------------------------------------------------------- /src/app/core.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, Optional, SkipSelf } from '@angular/core'; 2 | import { TestsModule } from './tests.module'; 3 | 4 | export function throwIfAlreadyLoaded(parentModule: any, moduleName: string) { 5 | if (parentModule) { 6 | throw new Error(`${moduleName} has already been loaded. Import Core modules in the AppModule only.`); 7 | } 8 | } 9 | 10 | @NgModule({ 11 | imports: [ TestsModule ], 12 | }) 13 | export class CoreModule { 14 | constructor( @Optional() @SkipSelf() parentModule: CoreModule) { 15 | throwIfAlreadyLoaded(parentModule, 'CoreModule'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { AppComponent } from './app.component'; 4 | import { CoreModule } from './core.module'; 5 | import { SharedModule } from './shared.module'; 6 | import { OldComponent } from './old.component'; 7 | import { NewComponent } from './new.component'; 8 | 9 | @NgModule({ 10 | declarations: [ AppComponent, OldComponent, NewComponent ], 11 | imports: [ BrowserModule, SharedModule, CoreModule ], 12 | providers: [], 13 | bootstrap: [ AppComponent ], 14 | }) 15 | export class AppModule {} 16 | -------------------------------------------------------------------------------- /src/app/modules/angular-ab-tests/server/ab-tests-server.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {CookieHandler, CrawlerDetector} from '../classes'; 3 | import {ServerCrawlerDetectorService} from './server-crawler-detector.service'; 4 | import {ServerCookieHandlerService} from './server-cookie-handler.service'; 5 | 6 | @NgModule({ 7 | providers: [ 8 | { 9 | provide: CrawlerDetector, 10 | useClass: ServerCrawlerDetectorService 11 | }, 12 | { 13 | provide: CookieHandler, 14 | useClass: ServerCookieHandlerService 15 | }, 16 | ] 17 | }) 18 | export class AbTestsServerModule { 19 | 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * In development mode, to ignore zone related error stack frames such as 11 | * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can 12 | * import the following file, but please comment it out in production mode 13 | * because it will have performance impact when throw error 14 | */ 15 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 16 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment( 14 | BrowserDynamicTestingModule, 15 | platformBrowserDynamicTesting() 16 | ); 17 | // Then we find all the tests. 18 | const context = require.context('./', true, /\.spec\.ts$/); 19 | // And load the modules. 20 | context.keys().map(context); 21 | -------------------------------------------------------------------------------- /src/app/old.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, DoCheck } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'old-component', 5 | template: ` 6 |
7 |

This is the old component

8 | 9 |

{{message}}

10 |
11 | ` 12 | }) 13 | export class OldComponent implements OnChanges, DoCheck { 14 | public message: string; 15 | 16 | @Input() 17 | set value(x: string) { 18 | this.message = 'Value passed along is <<' + x + '>>'; 19 | } 20 | 21 | ngOnChanges() { 22 | console.log('Triggered onChanges for OldComponent'); 23 | } 24 | 25 | ngDoCheck() { 26 | console.log('Triggered doCheck for OldComponent'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/new.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, DoCheck } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'new-component', 5 | template: ` 6 |
7 |

This is the new component

8 | 9 |

{{message}}

10 |
11 | ` 12 | }) 13 | export class NewComponent implements OnChanges, DoCheck { 14 | public message: string; 15 | 16 | @Input() 17 | set value(x: string) { 18 | this.message = 'Value passed along is <<' + x + '>>'; 19 | } 20 | 21 | ngOnChanges() { 22 | console.log('Triggered onChanges for NewComponent'); 23 | } 24 | 25 | ngDoCheck() { 26 | console.log('Triggered doCheck for NewComponent'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/modules/angular-ab-tests/server/server-crawler-detector.service.ts: -------------------------------------------------------------------------------- 1 | import {AbstractUserAgentCrawlerDetector} from '../classes'; 2 | import {Inject, Injectable} from '@angular/core'; 3 | import {REQUEST} from '@nguniversal/express-engine/tokens'; 4 | 5 | 6 | @Injectable({ 7 | providedIn: 'root', 8 | }) 9 | export class ServerCrawlerDetectorService extends AbstractUserAgentCrawlerDetector { 10 | 11 | constructor(@Inject(REQUEST) private httpRequest) { 12 | super(); 13 | } 14 | 15 | protected getUserAgentString(): string { 16 | if (this.httpRequest) { 17 | const useAgentHeader = this.httpRequest.headers['user-agent']; 18 | return Array.isArray(useAgentHeader) ? useAgentHeader[0] : useAgentHeader; 19 | } 20 | return ''; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: [ 9 | './src/**/*.e2e-spec.ts' 10 | ], 11 | capabilities: { 12 | 'browserName': 'chrome' 13 | }, 14 | directConnect: true, 15 | baseUrl: 'http://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | onPrepare() { 23 | require('ts-node').register({ 24 | project: require('path').join(__dirname, './tsconfig.e2e.json') 25 | }); 26 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | } 28 | }; -------------------------------------------------------------------------------- /src/app/modules/angular-ab-tests/server/server-cookie-handler.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, Optional} from '@angular/core'; 2 | import {CookiesService} from '@ngx-utils/cookies'; 3 | import {CookieHandler} from '../classes'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class ServerCookieHandlerService implements CookieHandler { 9 | constructor(@Optional() private cookieService: CookiesService) { 10 | 11 | } 12 | 13 | get(name: string): string { 14 | if (this.cookieService) { 15 | return this.cookieService.get(name); 16 | } 17 | } 18 | 19 | set(name: string, value: string, domain?: string, expires?: number): void { 20 | if (this.cookieService) { 21 | this.cookieService.put(name, value, { 22 | domain, 23 | expires: expires ? new Date(new Date().getTime() + expires * 1000 * 60 * 60 * 24) : undefined 24 | }); 25 | } 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../coverage'), 20 | reports: ['html', 'lcovonly'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false 30 | }); 31 | }; -------------------------------------------------------------------------------- /src/app/modules/angular-ab-tests/module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, ModuleWithProviders } from '@angular/core'; 2 | import { AbTestsService } from './service'; 3 | import { CONFIG } from './injection-tokens'; 4 | import { CookieHandler, CrawlerDetector, RandomExtractor } from './classes'; 5 | import { AbTestVersionDirective } from './directive'; 6 | 7 | export interface AbTestOptions { 8 | versions: string[]; 9 | domain?: string; 10 | versionForCrawlers?: string; 11 | scope?: string; 12 | expiration?: number; 13 | weights?: { 14 | [x: string]: number, 15 | }; 16 | } 17 | 18 | @NgModule({ 19 | declarations: [ 20 | AbTestVersionDirective 21 | ], 22 | exports: [ 23 | AbTestVersionDirective 24 | ], 25 | }) 26 | export class AbTestsModule { 27 | static forRoot(configs: AbTestOptions[]): ModuleWithProviders { 28 | return { 29 | ngModule: AbTestsModule, 30 | providers: [ 31 | AbTestsService, 32 | { provide: CONFIG, useValue: configs }, 33 | CookieHandler, 34 | CrawlerDetector, 35 | RandomExtractor, 36 | ], 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Adriano di Lauro 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/app/modules/angular-ab-tests/directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, OnInit, ViewContainerRef, TemplateRef, Input } from '@angular/core'; 2 | import { AbTestsService } from './service'; 3 | 4 | @Directive({ 5 | selector: '[abTestVersion]' 6 | }) 7 | export class AbTestVersionDirective implements OnInit { 8 | private _versions: string[]; 9 | private _scope: string; 10 | private _forCrawlers: boolean = false; 11 | 12 | constructor( 13 | private _service: AbTestsService, 14 | private _viewContainer: ViewContainerRef, 15 | private _templateRef: TemplateRef 16 | ) {} 17 | 18 | ngOnInit() { 19 | if (this._service.shouldRender(this._versions, this._scope, this._forCrawlers)) { 20 | this._viewContainer.createEmbeddedView(this._templateRef); 21 | } 22 | } 23 | 24 | @Input() 25 | set abTestVersion(value: string) { 26 | this._versions = value.split(','); 27 | } 28 | 29 | @Input() 30 | set abTestVersionScope(value: string) { 31 | this._scope = value; 32 | } 33 | 34 | @Input() 35 | set abTestVersionForCrawlers(value: boolean) { 36 | this._forCrawlers = value; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { AbTestsService } from './modules/angular-ab-tests/service'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | template: ` 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Click here to randomize! 19 | 20 | ` 21 | }) 22 | export class AppComponent implements OnInit { 23 | public values = ['John Lennon', 'Ringo Starr', 'Paul McCartney', 'George Harrison']; 24 | public randomizedIndex: number = this.getRandomIndex(); 25 | 26 | constructor(private abTestsService: AbTestsService) {} 27 | 28 | randomizeValue() { 29 | this.randomizedIndex = this.getRandomIndex(); 30 | } 31 | 32 | ngOnInit() { 33 | console.log(this.abTestsService.getVersion()); 34 | } 35 | 36 | private getRandomIndex() { 37 | return Math.floor(Math.random() * 100) % 4; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-ab-tests", 3 | "version": "1.3.1", 4 | "description": "Easy and descriptive way to setup complex AB tests and multivariate tests in Angular2+", 5 | "author": "Adriano di Lauro", 6 | "license": "MIT", 7 | "keywords": [ 8 | "angular", 9 | "angular2", 10 | "angular 2", 11 | "angular4", 12 | "angular 4", 13 | "angular5", 14 | "angular 5", 15 | "angular6", 16 | "angular 6", 17 | "ab tests", 18 | "tests", 19 | "test", 20 | "ab test", 21 | "multivariate tests", 22 | "multivariate test", 23 | "multivariate", 24 | "ab" 25 | ], 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/adrdilauro/angular-ab-tests.git" 29 | }, 30 | "homepage": "https://github.com/adrdilauro/angular-ab-tests", 31 | "bugs": { 32 | "url": "https://github.com/adrdilauro/angular-ab-tests/issues" 33 | }, 34 | "dependencies": { 35 | "@angular/animations": "^6.0.0", 36 | "@angular/common": "^6.0.0", 37 | "@angular/compiler": "^6.0.0", 38 | "@angular/core": "^6.0.0", 39 | "@angular/forms": "^6.0.0", 40 | "@angular/http": "^6.0.0", 41 | "@angular/platform-browser": "^6.0.0", 42 | "@angular/platform-browser-dynamic": "^6.0.0", 43 | "@angular/router": "^6.0.0", 44 | "core-js": "^2.5.4", 45 | "rxjs": "^6.0.0", 46 | "zone.js": "^0.8.26" 47 | }, 48 | "devDependencies": { 49 | "@angular-devkit/build-angular": "~0.6.0", 50 | "@angular/cli": "~6.0.0", 51 | "@angular/compiler-cli": "^6.0.0", 52 | "@angular/language-service": "^6.0.0", 53 | "@nguniversal/express-engine": "^7.1.1", 54 | "@ngx-utils/cookies": "^3.0.2", 55 | "@types/jasmine": "~2.8.6", 56 | "@types/jasminewd2": "~2.0.3", 57 | "@types/node": "~8.9.4", 58 | "codelyzer": "~4.2.1", 59 | "jasmine-core": "~2.99.1", 60 | "jasmine-spec-reporter": "~4.2.1", 61 | "karma": "~1.7.1", 62 | "karma-chrome-launcher": "~2.2.0", 63 | "karma-coverage-istanbul-reporter": "~1.4.2", 64 | "karma-jasmine": "~1.1.1", 65 | "karma-jasmine-html-reporter": "^0.2.2", 66 | "ng-packagr": "^3.0.0", 67 | "protractor": "~5.3.0", 68 | "ts-node": "~5.0.1", 69 | "tslint": "~5.9.1", 70 | "typescript": "~2.7.2" 71 | }, 72 | "scripts": { 73 | "ng": "ng", 74 | "start": "ng serve", 75 | "build": "ng build", 76 | "test": "ng test", 77 | "lint": "ng lint", 78 | "e2e": "ng e2e", 79 | "packagr": "ng-packagr -p ng-package.json" 80 | }, 81 | "private": false 82 | } 83 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "deprecation": { 15 | "severity": "warn" 16 | }, 17 | "eofline": true, 18 | "forin": true, 19 | "import-blacklist": [ 20 | true, 21 | "rxjs/Rx" 22 | ], 23 | "import-spacing": true, 24 | "indent": [ 25 | true, 26 | "spaces" 27 | ], 28 | "interface-over-type-literal": true, 29 | "label-position": true, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-arg": true, 47 | "no-bitwise": true, 48 | "no-console": [ 49 | true, 50 | "debug", 51 | "info", 52 | "time", 53 | "timeEnd", 54 | "trace" 55 | ], 56 | "no-construct": true, 57 | "no-debugger": true, 58 | "no-duplicate-super": true, 59 | "no-empty": false, 60 | "no-empty-interface": true, 61 | "no-eval": true, 62 | "no-inferrable-types": [ 63 | true, 64 | "ignore-params" 65 | ], 66 | "no-misused-new": true, 67 | "no-non-null-assertion": true, 68 | "no-shadowed-variable": true, 69 | "no-string-literal": false, 70 | "no-string-throw": true, 71 | "no-switch-case-fall-through": true, 72 | "no-trailing-whitespace": true, 73 | "no-unnecessary-initializer": true, 74 | "no-unused-expression": true, 75 | "no-use-before-declare": true, 76 | "no-var-keyword": true, 77 | "object-literal-sort-keys": false, 78 | "one-line": [ 79 | true, 80 | "check-open-brace", 81 | "check-catch", 82 | "check-else", 83 | "check-whitespace" 84 | ], 85 | "prefer-const": true, 86 | "quotemark": [ 87 | true, 88 | "single" 89 | ], 90 | "radix": true, 91 | "semicolon": [ 92 | true, 93 | "always" 94 | ], 95 | "triple-equals": [ 96 | true, 97 | "allow-null-check" 98 | ], 99 | "typedef-whitespace": [ 100 | true, 101 | { 102 | "call-signature": "nospace", 103 | "index-signature": "nospace", 104 | "parameter": "nospace", 105 | "property-declaration": "nospace", 106 | "variable-declaration": "nospace" 107 | } 108 | ], 109 | "unified-signatures": true, 110 | "variable-name": false, 111 | "whitespace": [ 112 | true, 113 | "check-branch", 114 | "check-decl", 115 | "check-operator", 116 | "check-separator", 117 | "check-type" 118 | ], 119 | "no-output-on-prefix": true, 120 | "use-input-property-decorator": true, 121 | "use-output-property-decorator": true, 122 | "use-host-property-decorator": true, 123 | "no-input-rename": true, 124 | "no-output-rename": true, 125 | "use-life-cycle-interface": true, 126 | "use-pipe-transform-interface": true, 127 | "component-class-suffix": true, 128 | "directive-class-suffix": true 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 22 | // import 'core-js/es6/symbol'; 23 | // import 'core-js/es6/object'; 24 | // import 'core-js/es6/function'; 25 | // import 'core-js/es6/parse-int'; 26 | // import 'core-js/es6/parse-float'; 27 | // import 'core-js/es6/number'; 28 | // import 'core-js/es6/math'; 29 | // import 'core-js/es6/string'; 30 | // import 'core-js/es6/date'; 31 | // import 'core-js/es6/array'; 32 | // import 'core-js/es6/regexp'; 33 | // import 'core-js/es6/map'; 34 | // import 'core-js/es6/weak-map'; 35 | // import 'core-js/es6/set'; 36 | 37 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 38 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 39 | 40 | /** IE10 and IE11 requires the following for the Reflect API. */ 41 | // import 'core-js/es6/reflect'; 42 | 43 | 44 | /** Evergreen browsers require these. **/ 45 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. 46 | import 'core-js/es7/reflect'; 47 | 48 | 49 | /** 50 | * Web Animations `@angular/platform-browser/animations` 51 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 52 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 53 | **/ 54 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 55 | 56 | /** 57 | * By default, zone.js will patch all possible macroTask and DomEvents 58 | * user can disable parts of macroTask/DomEvents patch by setting following flags 59 | */ 60 | 61 | // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 62 | // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 63 | // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 64 | 65 | /* 66 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 67 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 68 | */ 69 | // (window as any).__Zone_enable_cross_context_check = true; 70 | 71 | /*************************************************************************************************** 72 | * Zone JS is required by default for Angular itself. 73 | */ 74 | import 'zone.js/dist/zone'; // Included with Angular CLI. 75 | 76 | 77 | 78 | /*************************************************************************************************** 79 | * APPLICATION IMPORTS 80 | */ 81 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular-ab-tests": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "prefix": "app", 11 | "schematics": {}, 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/angular-ab-tests", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": "src/polyfills.ts", 20 | "tsConfig": "src/tsconfig.app.json", 21 | "assets": [ 22 | "src/favicon.ico", 23 | "src/assets" 24 | ], 25 | "styles": [ 26 | "src/styles.css" 27 | ], 28 | "scripts": [] 29 | }, 30 | "configurations": { 31 | "production": { 32 | "fileReplacements": [ 33 | { 34 | "replace": "src/environments/environment.ts", 35 | "with": "src/environments/environment.prod.ts" 36 | } 37 | ], 38 | "optimization": true, 39 | "outputHashing": "all", 40 | "sourceMap": false, 41 | "extractCss": true, 42 | "namedChunks": false, 43 | "aot": true, 44 | "extractLicenses": true, 45 | "vendorChunk": false, 46 | "buildOptimizer": true 47 | } 48 | } 49 | }, 50 | "serve": { 51 | "builder": "@angular-devkit/build-angular:dev-server", 52 | "options": { 53 | "browserTarget": "angular-ab-tests:build" 54 | }, 55 | "configurations": { 56 | "production": { 57 | "browserTarget": "angular-ab-tests:build:production" 58 | } 59 | } 60 | }, 61 | "extract-i18n": { 62 | "builder": "@angular-devkit/build-angular:extract-i18n", 63 | "options": { 64 | "browserTarget": "angular-ab-tests:build" 65 | } 66 | }, 67 | "test": { 68 | "builder": "@angular-devkit/build-angular:karma", 69 | "options": { 70 | "main": "src/test.ts", 71 | "polyfills": "src/polyfills.ts", 72 | "tsConfig": "src/tsconfig.spec.json", 73 | "karmaConfig": "src/karma.conf.js", 74 | "styles": [ 75 | "styles.css" 76 | ], 77 | "scripts": [], 78 | "assets": [ 79 | "src/favicon.ico", 80 | "src/assets" 81 | ] 82 | } 83 | }, 84 | "lint": { 85 | "builder": "@angular-devkit/build-angular:tslint", 86 | "options": { 87 | "tsConfig": [ 88 | "src/tsconfig.app.json", 89 | "src/tsconfig.spec.json" 90 | ], 91 | "exclude": [ 92 | "**/node_modules/**" 93 | ] 94 | } 95 | } 96 | } 97 | }, 98 | "angular-ab-tests-e2e": { 99 | "root": "e2e/", 100 | "projectType": "application", 101 | "architect": { 102 | "e2e": { 103 | "builder": "@angular-devkit/build-angular:protractor", 104 | "options": { 105 | "protractorConfig": "e2e/protractor.conf.js", 106 | "devServerTarget": "angular-ab-tests:serve" 107 | } 108 | }, 109 | "lint": { 110 | "builder": "@angular-devkit/build-angular:tslint", 111 | "options": { 112 | "tsConfig": "e2e/tsconfig.e2e.json", 113 | "exclude": [ 114 | "**/node_modules/**" 115 | ] 116 | } 117 | } 118 | } 119 | } 120 | }, 121 | "defaultProject": "angular-ab-tests" 122 | } -------------------------------------------------------------------------------- /src/app/modules/angular-ab-tests/classes.ts: -------------------------------------------------------------------------------- 1 | import { error } from './error'; 2 | 3 | export class AbTestForRealUser { 4 | private _versions: string[] = []; 5 | private _chosenVersion: string; 6 | 7 | constructor(versions: string[], chosenVersion: string) { 8 | this._versions = versions; 9 | this._chosenVersion = chosenVersion; 10 | } 11 | 12 | getVersion(): string { 13 | return this._chosenVersion; 14 | } 15 | 16 | setVersion(version: string) { 17 | if (this._versions.indexOf(version) === -1) { 18 | error('Version <' + version + '> has not been declared: [ ' + this._versions.join(', ') + ' ]'); 19 | } 20 | this._chosenVersion = version; 21 | } 22 | 23 | shouldRender(versions: string[], forCrawlers: boolean): boolean { 24 | for (let version of versions) { 25 | if (this._versions.indexOf(version) === -1) { 26 | error('Version <' + version + '> has not been declared: [ ' + this._versions.join(', ') + ' ]'); 27 | } 28 | } 29 | return (versions.indexOf(this._chosenVersion) !== -1); 30 | } 31 | } 32 | 33 | export class AbTestForCrawler { 34 | private _version: string; 35 | 36 | constructor(version?: string) { 37 | if (!!version) { 38 | this._version = version; 39 | } 40 | } 41 | 42 | getVersion(): string { 43 | return ''; 44 | } 45 | 46 | setVersion(version: string) {} 47 | 48 | shouldRender(versions: string[], forCrawlers: boolean): boolean { 49 | return forCrawlers || (!!this._version && versions.indexOf(this._version) !== -1); 50 | } 51 | } 52 | 53 | export class RandomExtractor { 54 | private _weights: [number, string][]; 55 | private _versions: string[]; 56 | 57 | setWeights(weights: [number, string][]) { 58 | this._weights = weights; 59 | } 60 | 61 | setVersions(versions: string[]) { 62 | this._versions = versions; 63 | } 64 | 65 | run(): string { 66 | if (this._weights.length === 0) { 67 | return this._versions[Math.floor(Math.random() * this._versions.length)]; 68 | } 69 | let random: number = Math.random() * 100; 70 | for (let weight of this._weights) { 71 | if (random <= weight[0]) { 72 | return weight[1]; 73 | } 74 | } 75 | return this._versions[0]; 76 | } 77 | } 78 | 79 | export abstract class AbstractUserAgentCrawlerDetector { 80 | private _regexps: RegExp[] = [ 81 | /bot/i, /spider/i, /facebookexternalhit/i, /simplepie/i, /yahooseeker/i, /embedly/i, 82 | /quora link preview/i, /outbrain/i, /vkshare/i, /monit/i, /Pingability/i, /Monitoring/i, 83 | /WinHttpRequest/i, /Apache-HttpClient/i, /getprismatic.com/i, /python-requests/i, /Twurly/i, 84 | /yandex/i, /browserproxy/i, /Monitoring/i, /crawler/i, /Qwantify/i, /Yahoo! Slurp/i, /pinterest/i 85 | ]; 86 | 87 | isCrawler() { 88 | return this._regexps.some((crawler) => { 89 | return crawler.test(this.getUserAgentString()); 90 | }); 91 | } 92 | 93 | protected abstract getUserAgentString(): string; 94 | } 95 | 96 | export class CrawlerDetector extends AbstractUserAgentCrawlerDetector { 97 | 98 | protected getUserAgentString() { 99 | return window.navigator.userAgent; 100 | } 101 | } 102 | 103 | export class CookieHandler { 104 | public get(name: string): string { 105 | name = encodeURIComponent(name); 106 | let regexp: RegExp = new RegExp('(?:^' + name + '|;\\s*' + name + ')=(.*?)(?:;|$)', 'g'); 107 | let results = regexp.exec(document.cookie); 108 | return (!results) ? '' : decodeURIComponent(results[1]); 109 | } 110 | 111 | public set(name: string, value: string, domain?: string, expires?: number) { 112 | let cookieStr = encodeURIComponent(name) + '=' + encodeURIComponent(value) + ';'; 113 | if (expires) { 114 | let dtExpires = new Date(new Date().getTime() + expires * 1000 * 60 * 60 * 24); 115 | cookieStr += 'expires=' + dtExpires.toUTCString() + ';'; 116 | } 117 | if (domain) { 118 | cookieStr += 'domain=' + domain + ';'; 119 | } 120 | document.cookie = cookieStr; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/app/modules/angular-ab-tests/service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@angular/core'; 2 | import { AbTestOptions } from './module'; 3 | import { AbTestForRealUser, AbTestForCrawler, CookieHandler, CrawlerDetector, RandomExtractor } from './classes'; 4 | import { CONFIG } from './injection-tokens'; 5 | import { error } from './error'; 6 | 7 | export const COOKIE_NAMESPACE = 'angular-ab-tests'; 8 | 9 | @Injectable() 10 | export class AbTestsService { 11 | private _tests: { [x: string]: AbTestForRealUser | AbTestForCrawler } = {}; 12 | private _cookieHandler: CookieHandler; 13 | private _randomExtractor: RandomExtractor; 14 | private _defaultScope: string = 'default'; 15 | 16 | constructor( 17 | @Inject(CONFIG) configs: AbTestOptions[], 18 | cookieHandler: CookieHandler, 19 | crawlerDetector: CrawlerDetector, 20 | randomExtractor: RandomExtractor 21 | ) { 22 | this._cookieHandler = cookieHandler; 23 | this._randomExtractor = randomExtractor; 24 | var isCrawler: boolean = crawlerDetector.isCrawler(); 25 | for (let config of configs) { 26 | let scope: string = this._defaultScope; 27 | if (!!config.scope) { 28 | scope = config.scope; 29 | } 30 | if (!!this._tests[scope]) { 31 | error('Test with scope <' + scope + '> cannot be initialized twice'); 32 | } 33 | if (isCrawler) { 34 | this.setupTestForCrawler(scope, this.filterVersions(config.versions), config); 35 | } else { 36 | this.setupTestForRealUser(scope, this.filterVersions(config.versions), config); 37 | } 38 | } 39 | } 40 | 41 | getVersion(scope?: string): string { 42 | return this.getTest(scope).getVersion(); 43 | } 44 | 45 | setVersion(version: string, scope?: string) { 46 | return this.getTest(scope).setVersion(version); 47 | } 48 | 49 | shouldRender(versions: string[], scope: string, forCrawlers: boolean): boolean { 50 | return this.getTest(scope).shouldRender(versions, forCrawlers); 51 | } 52 | 53 | private getTest(scope?: string): AbTestForRealUser | AbTestForCrawler { 54 | let scopeOrDefault = scope || this._defaultScope; 55 | if (!this._tests[scopeOrDefault]) { 56 | error('Test with scope <' + scopeOrDefault + '> has not been defined'); 57 | } 58 | return this._tests[scopeOrDefault]; 59 | } 60 | 61 | private filterVersions(versions: string[]): string[] { 62 | let resp:string[] = []; 63 | if (versions.length < 2) { 64 | error('You have to provide at least two versions'); 65 | } 66 | for (let version of versions) { 67 | if (resp.indexOf(version) !== -1) { 68 | error('Version <' + version + '> is repeated in the array of versions [ ' + versions.join(', ') + ' ]'); 69 | } 70 | resp.push(version); 71 | } 72 | return resp; 73 | } 74 | 75 | private setupTestForCrawler(scope: string, versions: string[], config: AbTestOptions) { 76 | if (!!config.versionForCrawlers && versions.indexOf(config.versionForCrawlers) === -1) { 77 | error('Version for crawlers <' + config.versionForCrawlers + '> is not included in versions [ ' + versions.join(', ') + ' ]'); 78 | } 79 | this._tests[scope] = new AbTestForCrawler(config.versionForCrawlers); 80 | } 81 | 82 | private setupTestForRealUser(scope: string, versions: string[], config: AbTestOptions) { 83 | let chosenVersion: string = this.generateVersion({ 84 | versions: versions, 85 | cookieName: COOKIE_NAMESPACE + '-' + scope, 86 | domain: config.domain, 87 | expiration: config.expiration, 88 | weights: config.weights, 89 | }); 90 | this._tests[scope] = new AbTestForRealUser(versions, chosenVersion); 91 | } 92 | 93 | private generateVersion(config: { 94 | versions: string[], 95 | cookieName: string, 96 | domain?: string, 97 | expiration?: number, 98 | weights?: { [x: string]: number }; 99 | }): string { 100 | let chosenVersion: string = this._cookieHandler.get(config.cookieName); 101 | if (config.versions.indexOf(chosenVersion) !== -1) { 102 | return chosenVersion; 103 | } 104 | this._randomExtractor.setWeights(this.processWeights(config.weights || {}, config.versions)); 105 | this._randomExtractor.setVersions(config.versions); 106 | chosenVersion = this._randomExtractor.run(); 107 | this._cookieHandler.set(config.cookieName, chosenVersion, config.domain, config.expiration); 108 | return chosenVersion; 109 | } 110 | 111 | private processWeights(weights: { [x: string]: number }, versions: string[]): [number, string][] { 112 | let processedWeights: [number, string][] = []; 113 | let totalWeight: number = 0; 114 | let tempVersions: string[] = versions.slice(0); 115 | let index: number = -100; 116 | for (let key in weights) { 117 | index = tempVersions.indexOf(key); 118 | if (index === -1) { 119 | error('Weight associated to <' + key + '> which is not included in versions [ ' + versions.join(', ') + ' ]'); 120 | } 121 | tempVersions.splice(index, 1); 122 | totalWeight += this.roundFloat(weights[key]); 123 | processedWeights.push([totalWeight, key]); 124 | } 125 | if (index === -100) { 126 | return []; 127 | } 128 | if (totalWeight >= 100) { 129 | error('Sum of weights is <' + totalWeight + '>, while it should be less than 100'); 130 | } 131 | let remainingWeight: number = this.roundFloat((100 - totalWeight) / tempVersions.length); 132 | for (let version of tempVersions) { 133 | totalWeight += remainingWeight; 134 | processedWeights.push([totalWeight, version]); 135 | } 136 | processedWeights[processedWeights.length - 1] = [100, processedWeights[processedWeights.length - 1][1]]; 137 | return processedWeights; 138 | } 139 | 140 | private roundFloat(x: number): number { 141 | return Math.round(x * 1000) / 1000; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/app/directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { By } from '@angular/platform-browser'; 4 | import { AbTestsModule } from './modules/angular-ab-tests/module'; 5 | import { AbTestVersionDirective } from './modules/angular-ab-tests/directive'; 6 | import {CookieHandler, CrawlerDetector, RandomExtractor} from './modules/angular-ab-tests/classes'; 7 | 8 | @Component({ 9 | template: ` 10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | `, 19 | }) 20 | class TestAbTestVersionsComponent {} 21 | 22 | let setUpSpies = function(versionsCookie, colorsCookie, defaultCookie, isCrawler?) { 23 | let randomized = [ 'v1', 'red', 'old' ]; 24 | let randomizedIndex = 0; 25 | return { 26 | cookieHandler: { 27 | get: spyOn(TestBed.get(CookieHandler), 'get').and.callFake(function(cookieName) { 28 | switch(cookieName) { 29 | case 'angular-ab-tests-versions': 30 | return versionsCookie; 31 | case 'angular-ab-tests-colors': 32 | return colorsCookie; 33 | case 'angular-ab-tests-default': 34 | return defaultCookie; 35 | } 36 | }), 37 | set: spyOn(TestBed.get(CookieHandler), 'set'), 38 | }, 39 | randomExtractor: { 40 | setWeights: spyOn(TestBed.get(RandomExtractor), 'setWeights'), 41 | setVersions: spyOn(TestBed.get(RandomExtractor), 'setVersions').and.callFake(function(versions) { 42 | randomizedIndex = randomized.indexOf(versions[0]); 43 | }), 44 | run: spyOn(TestBed.get(RandomExtractor), 'run').and.callFake(function(cookieName) { 45 | return randomized[randomizedIndex]; 46 | }), 47 | }, 48 | crawlerDetector: { 49 | isCrawler: spyOn(TestBed.get(CrawlerDetector), 'isCrawler').and.returnValue(!!isCrawler), 50 | }, 51 | }; 52 | } 53 | 54 | let testCall = { 55 | randomExtractor: { 56 | versions: function(arg) { 57 | expect(arg.length).toBe(3); 58 | expect(arg[0][0]).toBe(45); 59 | expect(arg[0][1]).toBe('v1'); 60 | expect(arg[1][0]).toBe(78.333); 61 | expect(arg[1][1]).toBe('v3'); 62 | expect(arg[2][0]).toBe(100); 63 | expect(arg[2][1]).toBe('v2'); 64 | }, 65 | colors: function(arg) { 66 | expect(arg.length).toBe(0); 67 | }, 68 | default: function(arg) { 69 | expect(arg.length).toBe(2); 70 | expect(arg[0][0]).toBe(60); 71 | expect(arg[0][1]).toBe('old'); 72 | expect(arg[1][0]).toBe(100); 73 | expect(arg[1][1]).toBe('new'); 74 | }, 75 | }, 76 | cookieHandler: { 77 | versions: function(arg) { 78 | expect(arg.length).toBe(4); 79 | expect(arg[0]).toBe('angular-ab-tests-versions'); 80 | expect(arg[1]).toBe('v1'); 81 | expect(arg[2]).toBe(undefined); 82 | expect(arg[3]).toBe(45); 83 | }, 84 | colors: function(arg) { 85 | expect(arg.length).toBe(4); 86 | expect(arg[0]).toBe('angular-ab-tests-colors'); 87 | expect(arg[1]).toBe('red'); 88 | expect(arg[2]).toBe(undefined); 89 | expect(arg[3]).toBe(undefined); 90 | }, 91 | default: function(arg) { 92 | expect(arg.length).toBe(4); 93 | expect(arg[0]).toBe('angular-ab-tests-default'); 94 | expect(arg[1]).toBe('old'); 95 | expect(arg[2]).toBe('xxx.xxx'); 96 | expect(arg[3]).toBe(undefined); 97 | }, 98 | } 99 | }; 100 | 101 | let testCallsSetWeights = function(calls, toExpect) { 102 | expect(calls.length).toBe(toExpect.length); 103 | for (var i = 0; i < toExpect.length; i++) { 104 | testCall.randomExtractor[toExpect[i]](calls[i].args[0]); 105 | } 106 | } 107 | 108 | let testCallsSetCookie = function(calls, toExpect) { 109 | expect(calls.length).toBe(toExpect.length); 110 | for (var i = 0; i < toExpect.length; i++) { 111 | testCall.cookieHandler[toExpect[i]](calls[i].args); 112 | } 113 | } 114 | 115 | describe('Directive: AbTestVersion', () => { 116 | let fixture: ComponentFixture; 117 | 118 | beforeEach(() => { 119 | TestBed.configureTestingModule({ 120 | imports: [ 121 | AbTestsModule.forRoot( 122 | [ 123 | { 124 | versions: [ 'v1', 'v2', 'v3' ], 125 | scope: 'versions', 126 | expiration: 45, 127 | weights: { v1: 45, v3: 100/3 } 128 | }, 129 | { 130 | versions: [ 'red', 'green', 'blue' ], 131 | scope: 'colors', 132 | versionForCrawlers: 'green', 133 | }, 134 | { 135 | versions: [ 'old', 'new' ], 136 | domain: 'xxx.xxx', 137 | versionForCrawlers: 'old', 138 | weights: { old: 60 } 139 | }, 140 | ] 141 | ), 142 | ], 143 | declarations: [ TestAbTestVersionsComponent ], 144 | }); 145 | }); 146 | 147 | it('null null old', () => { 148 | let spies = setUpSpies(null, null, 'old'); 149 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 150 | fixture.detectChanges(); 151 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 152 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 153 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 154 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(false); 155 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(false); 156 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(true); 157 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(true); 158 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(false); 159 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), ['versions', 'colors']); 160 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), ['versions', 'colors']); 161 | expect(spies.randomExtractor.run.calls.all().length).toBe(2); 162 | }); 163 | 164 | it('null null null', () => { 165 | let spies = setUpSpies(null, null, null); 166 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 167 | fixture.detectChanges(); 168 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 169 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 170 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 171 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(false); 172 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(false); 173 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(true); 174 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(true); 175 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(false); 176 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), ['versions', 'colors', 'default']); 177 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), ['versions', 'colors', 'default']); 178 | expect(spies.randomExtractor.run.calls.all().length).toBe(3); 179 | }); 180 | 181 | it('v1 null new', () => { 182 | let spies = setUpSpies('v1', null, 'new'); 183 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 184 | fixture.detectChanges(); 185 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 186 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 187 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 188 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(false); 189 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(false); 190 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(true); 191 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(false); 192 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(true); 193 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), ['colors']); 194 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), ['colors']); 195 | expect(spies.randomExtractor.run.calls.all().length).toBe(1); 196 | }); 197 | 198 | it('v2 null null', () => { 199 | let spies = setUpSpies('v2', null, null); 200 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 201 | fixture.detectChanges(); 202 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 203 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(true); 204 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 205 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(false); 206 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(false); 207 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(true); 208 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(true); 209 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(false); 210 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), ['colors', 'default']); 211 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), ['colors', 'default']); 212 | expect(spies.randomExtractor.run.calls.all().length).toBe(2); 213 | }); 214 | 215 | it('v3 blue new', () => { 216 | let spies = setUpSpies('v3', 'blue', 'new'); 217 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 218 | fixture.detectChanges(); 219 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(false); 220 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 221 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(true); 222 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(true); 223 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(true); 224 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(false); 225 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(false); 226 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(true); 227 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), []); 228 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), []); 229 | expect(spies.randomExtractor.run.calls.all().length).toBe(0); 230 | }); 231 | 232 | it('v3 green null', () => { 233 | let spies = setUpSpies('v3', 'green', null); 234 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 235 | fixture.detectChanges(); 236 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(false); 237 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 238 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(true); 239 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(false); 240 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(true); 241 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(false); 242 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(true); 243 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(false); 244 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), ['default']); 245 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), ['default']); 246 | expect(spies.randomExtractor.run.calls.all().length).toBe(1); 247 | }); 248 | 249 | it('v2 blue new', () => { 250 | let spies = setUpSpies('v2', 'blue', 'new'); 251 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 252 | fixture.detectChanges(); 253 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 254 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(true); 255 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 256 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(true); 257 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(true); 258 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(false); 259 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(false); 260 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(true); 261 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), []); 262 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), []); 263 | expect(spies.randomExtractor.run.calls.all().length).toBe(0); 264 | }); 265 | 266 | it('null null old crawler', () => { 267 | let spies = setUpSpies(null, null, 'old', true); 268 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 269 | fixture.detectChanges(); 270 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 271 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 272 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 273 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(true); 274 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(true); 275 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(false); 276 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(true); 277 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(true); 278 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), []); 279 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), []); 280 | expect(spies.randomExtractor.run.calls.all().length).toBe(0); 281 | }); 282 | 283 | it('null null null crawler', () => { 284 | let spies = setUpSpies(null, null, null, true); 285 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 286 | fixture.detectChanges(); 287 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 288 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 289 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 290 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(true); 291 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(true); 292 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(false); 293 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(true); 294 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(true); 295 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), []); 296 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), []); 297 | expect(spies.randomExtractor.run.calls.all().length).toBe(0); 298 | }); 299 | 300 | it('v1 null new crawler', () => { 301 | let spies = setUpSpies('v1', null, 'new', true); 302 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 303 | fixture.detectChanges(); 304 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 305 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 306 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 307 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(true); 308 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(true); 309 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(false); 310 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(true); 311 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(true); 312 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), []); 313 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), []); 314 | expect(spies.randomExtractor.run.calls.all().length).toBe(0); 315 | }); 316 | 317 | it('v2 null null crawler', () => { 318 | let spies = setUpSpies('v2', null, null, true); 319 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 320 | fixture.detectChanges(); 321 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 322 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 323 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 324 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(true); 325 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(true); 326 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(false); 327 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(true); 328 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(true); 329 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), []); 330 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), []); 331 | expect(spies.randomExtractor.run.calls.all().length).toBe(0); 332 | }); 333 | 334 | it('v3 blue new crawler', () => { 335 | let spies = setUpSpies('v3', 'blue', 'new', true); 336 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 337 | fixture.detectChanges(); 338 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 339 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 340 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 341 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(true); 342 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(true); 343 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(false); 344 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(true); 345 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(true); 346 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), []); 347 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), []); 348 | expect(spies.randomExtractor.run.calls.all().length).toBe(0); 349 | }); 350 | 351 | it('v3 green null crawler', () => { 352 | let spies = setUpSpies('v3', 'green', null, true); 353 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 354 | fixture.detectChanges(); 355 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 356 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 357 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 358 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(true); 359 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(true); 360 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(false); 361 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(true); 362 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(true); 363 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), []); 364 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), []); 365 | expect(spies.randomExtractor.run.calls.all().length).toBe(0); 366 | }); 367 | 368 | it('v2 blue new crawler', () => { 369 | let spies = setUpSpies('v2', 'blue', 'new', true); 370 | fixture = TestBed.createComponent(TestAbTestVersionsComponent); 371 | fixture.detectChanges(); 372 | expect(!!fixture.debugElement.query(By.css('.versions-v1-v2'))).toBe(true); 373 | expect(!!fixture.debugElement.query(By.css('.versions-v2'))).toBe(false); 374 | expect(!!fixture.debugElement.query(By.css('.versions-v3'))).toBe(false); 375 | expect(!!fixture.debugElement.query(By.css('.colors-blue'))).toBe(true); 376 | expect(!!fixture.debugElement.query(By.css('.colors-blue-green'))).toBe(true); 377 | expect(!!fixture.debugElement.query(By.css('.colors-red'))).toBe(false); 378 | expect(!!fixture.debugElement.query(By.css('.default-old'))).toBe(true); 379 | expect(!!fixture.debugElement.query(By.css('.default-new'))).toBe(true); 380 | testCallsSetCookie(spies.cookieHandler.set.calls.all(), []); 381 | testCallsSetWeights(spies.randomExtractor.setWeights.calls.all(), []); 382 | expect(spies.randomExtractor.run.calls.all().length).toBe(0); 383 | }); 384 | }); 385 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | AngularAbTests is an [angular](https://angular.io/) module that helps you setting up **easily** and **clearly** any AB or multivariate test. 2 | 3 | It will **make your tests easy to debug and understand**, regardless of how complex they are, of how many versions you are setting up, or even of how many concurrent tests you are running. 4 | 5 | 6 | ### Update: version 1.3.0 has been released, here is the changelog 7 | 8 | 1. Added support for [server side rendering](#server-side-rendering). 9 | 10 | 11 | ### Contents 12 | 13 | - [List of features](#features) 14 | - [Quick introduction to usage](#usage-in-short) 15 | - [Why this plugin is good for you](#why-to-use-this-plugin) 16 | - [How to set up a quick demo to play around](#set-up-a-demo) 17 | - [Full documentation part 1: Initializing](#documentation-1-initializing) 18 | - [Full documentation part 2: Usage](#documentation-2-usage) 19 | - [Full documentation part 3: Tips](#documentation-3-tips) 20 | - [Server side rendering](#server-side-rendering) 21 | 22 | 23 | # Features 24 | 25 | - Set up an A/B test with a **simple and descriptive code** (the module makes available just one simple structural directive) 26 | - A/B tests are valid across different pages, using cookies with the expiration you want 27 | - Test **multiple versions** with changes as complicated as you need, while still keeping the code clean and understandable 28 | - Effortlessly set up **any number of independent A/B tests**, being sure that they won't clash in the code 29 | - Select a special version for crawlers, so you won't affect your SEO while you are running the test 30 | 31 | 32 | # Usage in short 33 | 34 | Add it to the list of dependencies using npm: https://www.npmjs.com/package/angular-ab-tests 35 | 36 | ``` 37 | npm install angular-ab-tests --save 38 | ``` 39 | 40 | Set up an A/B test including the module with `forRoot` method: 41 | 42 | ```javascript 43 | @NgModule({ 44 | imports: [ 45 | AbTestsModule.forRoot([ 46 | { 47 | versions: [ 'old', 'new' ], 48 | versionForCrawlers: 'old', 49 | expiration: 45, 50 | }, 51 | ]), 52 | ], 53 | exports: [ 54 | AbTestsModule, 55 | ], 56 | }) 57 | ``` 58 | 59 | Wrap fragments of HTML inside the structural directive named `abTestVersion`, marking them to belong to one or more of the versions of your A/B test. 60 | 61 | ```html 62 | 63 | Old version goes here 64 | 65 | 66 | 67 | New version goes here 68 | 69 | ``` 70 | 71 | 72 | # Why to use this plugin 73 | 74 | - You can create several different versions, as complex and as big as you need, without filling your HTML with unnecessary code. This will make your A/B test less error prone, and also it will make it easier to remove the loser versions after the test, because the code is clear and descriptive. 75 | - Versions that are not selected are automatically removed from change detection at initialization, so no performance issues. 76 | - You can easily span your tests across different pages reading the same cookie, with no additional code. 77 | - You can maintain as many different A/B tests you want without risking them to clash in the code. 78 | 79 | ### What about simple A/B tests? Why should I use AngularAbTests for a simple test as well? 80 | 81 | Usually, to set up a small A/B test (changing a color, removing or adding a div, etc) people use client side tools like Google Optimize. 82 | 83 | This approach can potentially affect user experience, because Google Optimize has to change parts of the page depending on the specific version selected, and, if this happens while the user is already looking at the page, we have the effect called "page flickering". To prevent page flickering Google Optimize introduced a "hide-page tag", i.e. a script that hides the page until the external call to Google Optimize server has responded. 84 | 85 | Now, usually Google Optimize tag loads fast, but you cannot always rely on external calls, especially in conditions of low network; in the worst scenario, if Google Optimize server doesn't respond, the hide-page tag gets unblocked after the threshold of 4 seconds. 86 | 87 | This means that, even if your server has responded in 150 milliseconds, your user won't be able to see anything in the page until the 4 seconds have expired. 88 | 89 | Are you sure you want to risk this? With AngularAbTests you can set up a simple A/B test easily and cleanly directly in the code, this means that you can get rid of the hide-page tag, and let Google Optimize focus only on data collection. 90 | 91 | 92 | # Set up a demo 93 | 94 | You can setup a simple demo to play around with the plugin and see how it works. 95 | 96 | 1. Execute `git clone git@github.com:adrdilauro/angular-ab-tests.git` 97 | 2. Navigate to repository folder 98 | 3. Execute `npm install` 99 | 4. Execute `ng serve` 100 | 5. Visit `http://0.0.0.0:4200` in your browser 101 | 102 | The demo contains a simple A/B test serving two different components depending on the chosen version. You can play around, add more tests / versions, and explore all the configuration options. 103 | 104 | **Keep in mind that in the demo `angular-ab-tests` is defined as a local module and not via npm, so if you want the demo code to work for your app you have to replace the imports:** 105 | 106 | ```javascript 107 | import { AbTestsModule } from './modules/angular-ab-tests/module'; // WRONG! Works only in the demo 108 | import { AbTestsModule } from 'angular-ab-tests'; // CORRECT, works when loading the module from npm 109 | ``` 110 | 111 | AngularAbTests is fully covered by specs: spec file is located in [src/app/directive.spec.ts](https://github.com/adrdilauro/angular-ab-tests/blob/master/src/app/directive.spec.ts), to run it in the demo you have to navigate in the root folder and execute `ng test` 112 | 113 | 114 | # Documentation 1: Initializing 115 | 116 | AngularAbTests declares just one directive called `*abTestVersion`. The directive is structural and it's used to wrap parts of the HTML that you want to A/B test. 117 | 118 | In order to use this directive, you need to import the module `AbTestsModule` wherever you need it. 119 | 120 | Besides importing the module, the other thing you need to do is initialize the global service the directive reads from: this service is used to store all the running A/B tests, and when it's called by the directive it returns the appropriate test with the version that is currently chosen. 121 | 122 | The service needs to be **global**, so it needs to be initialized at root level: for this reason, AngularAbTests provides a `forRoot` method to be used at the root of your application: 123 | 124 | ```javascript 125 | @NgModule({ 126 | imports: [ 127 | AbTestsModule.forRoot([ 128 | { 129 | // Configuration for the first test 130 | }, 131 | { 132 | // Configuration for the second test 133 | }, 134 | // etc 135 | ]), 136 | ], 137 | }) 138 | export class AppModule {} 139 | ``` 140 | 141 | I'll soon explain in detail (see [Documentation 2: Usage](#documentation-2-usage)) both the usage of the directive and the options available in each configuration object. Before coming to that, I want to give some practical tips about the set up process. 142 | 143 | ## Best practice for setting up AngularAbTests 144 | 145 | ### 1 - Set up the root import 146 | 147 | The best way to set up AngularAbTests is to include the `forRoot` call in your `CoreModule`, this is called the "forRoot pattern" (see [Angular's official documentation](https://angular.io/guide/ngmodule#configure-core-services-with-coremoduleforroot) for details: in a nutshell, when you need a service to be global under all circumstances, you cannot risk it to be included in modules that are lazy loaded, otherwise the lazy loaded module will be provided of a copy of the original service). 148 | 149 | So, if you follow the Angular best practice and have your own `CoreModule`, that's the best place to configure AngularAbTests: 150 | 151 | ```javascript 152 | @NgModule({ 153 | imports: [ 154 | SomeModuleWithProviders, 155 | AnotherModuleWithProviders, 156 | AbTestsModule.forRoot([ 157 | { 158 | // Configuration for the first test 159 | }, 160 | { 161 | // Configuration for the second test 162 | }, 163 | // etc 164 | ]), 165 | ], 166 | }) 167 | export class CoreModule { 168 | // Content of the CoreModule, usually a guard against double loading 169 | } 170 | ``` 171 | 172 | If you are setting up a lot of tests, you might want to clean up your `CoreModule` and extract the AngularAbTests logic into a separate one. Create a separate file called `tests.module.ts`: 173 | 174 | ```javascript 175 | import { NgModule } from '@angular/core'; 176 | import { AbTestsModule, AbTestOptions } from 'angular-ab-tests'; 177 | 178 | export const abTestsOptions: AbTestOptions[] = [ 179 | { 180 | // Configuration for the first test 181 | }, 182 | { 183 | // Configuration for the second test 184 | }, 185 | // etc 186 | ]; 187 | 188 | @NgModule({ 189 | imports: [ 190 | AbTestsModule.forRoot(abTestsOptions), 191 | ], 192 | }) 193 | export class TestsModule {} 194 | ``` 195 | 196 | In order to clean up better your module, you can declare the configuration options separately as a constant of type `AbTestOptions[]`: type `AbTestOptions` is imported from `angular-ab-tests`. Again, for a detailed description of configuration options, see [the second part of the documentation](#documentation-2-usage). 197 | 198 | To complete your refactoring, you then import your `TestsModule` into `CoreModule`: 199 | 200 | ```javascript 201 | @NgModule({ 202 | imports: [ 203 | SomeModuleWithProviders, 204 | AnotherModuleWithProviders, 205 | TestsModule, 206 | ], 207 | }) 208 | export class CoreModule { 209 | // Content of the CoreModule, usually a guard against double loading 210 | } 211 | ``` 212 | 213 | ### 2 - Set up the directive 214 | 215 | The best place to set up the directive `*abTestVersion` is the `SharedModule`, in order not to accidentallly forget to import it in every single module. Shared modules are [a recommended way to organize your shared dependencies](https://angular.io/guide/ngmodule#shared-modules). 216 | 217 | Simply configure your `SharedModule` to import and re-export the bare `AbTestsModule`, like this: 218 | 219 | ``` 220 | @NgModule({ 221 | imports: [ AbTestsModule ], 222 | exports: [ AbTestsModule ], 223 | }) 224 | export class SharedModule {} 225 | ``` 226 | 227 | To see quickly this whole configuration in action, [please set up the demo](#set-up-a-demo). 228 | 229 | To read more about `SharedModule` and `CoreModule`, you might find useful [this list of module patterns from the official docs](https://angular.io/guide/ngmodule-faq#feature-modules). 230 | 231 | 232 | 233 | # Documentation 2: Usage 234 | 235 | ### The config interface 236 | 237 | This is how the configuration `AbTestOptions` is defined: 238 | 239 | ```javascript 240 | export interface AbTestOptions { 241 | versions: string[]; 242 | scope?: string; 243 | versionForCrawlers?: string; 244 | expiration?: number; 245 | domain?: string; 246 | weights?: { 247 | [x: string]: number, 248 | }; 249 | } 250 | ``` 251 | 252 | When you setup the module using `forRoot`, you have to pass as argument **an array of objects that respect this interface**. Let's go over all the options: 253 | 254 | - `versions`: all the versions that your test is going to use (an array of strings): in order not to get confused, better using alphanumeric trimmed strings (anyway, if you accidentally mistype a version later AngularAbTests will raise an exception). 255 | - `scope`: if you are setting up more than one test at the same time, you have to specify a scope to distinguish them; if left undefined it will be automatically the string `'default'`. AngularAbTests will raise an exception if the same scope is used twice. 256 | - `versionForCrawlers`: use this field if you want one of the versions to systematically be shown to crawlers (you don't need to care about this if SEO is not important for you); of course the version needs to be one of the declared ones. 257 | - `expiration`: the number of days you want the cookie to persist; if left undefined the cookie will expire when the browser session ends. 258 | - `domain`: domain for the cookie (if left undefined it will use the standard domain). 259 | - `weights`: a hash of integers `< 100`, associated to the versions you have defined: use this option if you want some of your versions to appear mor frequently than the others. Weights for versions you didn't specify will be equally distributed. 260 | 261 | Examples of weight configurations for a list of versions `['v1', 'v2', 'v3']`: 262 | 263 | - `{ v1: 40 }` will produce a `40%` chance to extract `v1`, and `30%` for both the other two versions 264 | - `{ v1: 90, v2: 9 }` will produce a `90%` chance to extract `v1`, a `9%` to extract `v2`, and a remaining `1%` to extract `v3` 265 | - `{ v1: 50, v2: 55 }` will raise an exception because `50 + 55 = 105 > 100` 266 | - `{ v1: 40, v2: 30, v3: 35 }` will raise an exception because `40 + 30 + 35 = 105 > 100` 267 | - `{ v1: 40, v6: 45 }` will raise an exception because version `v6` hasn't been declared 268 | 269 | Example of a correct complete configuration 270 | 271 | ```javascript 272 | AbTestsModule.forRoot( 273 | [ 274 | { 275 | versions: [ 'v1', 'v2', 'v3' ], 276 | scope: 'versions', 277 | expiration: 45, 278 | weights: { v1: 45, v3: 100/3 } 279 | }, 280 | { 281 | versions: [ 'red', 'green', 'blue' ], 282 | scope: 'colors', 283 | versionForCrawlers: 'green', 284 | }, 285 | { 286 | versions: [ 'old', 'new' ], 287 | domain: 'xxx.xxx', 288 | versionForCrawlers: 'old', 289 | weights: { old: 60 } 290 | }, 291 | ] 292 | ) 293 | ``` 294 | 295 | ### The directive 296 | 297 | The directive `abTestVersion` wraps a portion of HTML and decides whether showing it or not depending on the following factors: 298 | 299 | 1. Does the version stored in the cookie match the one declared in the directive? 300 | 2. If the call comes from an SEO crawler, has this version been chosen to be shown to crawlers? 301 | 302 | This is the most basic implementation of the directive: 303 | 304 | ```html 305 | 306 | 307 | 308 | ``` 309 | 310 | The "scope" is necessary to map to the correct test if you set up more than one: if you are pointing to the default test, you can omit the scope, like this: 311 | 312 | ```html 313 | 314 | 315 | 316 | ``` 317 | 318 | You can associate one block of HTML to two versions: instead of writing the directive twice, 319 | 320 | ```html 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | ``` 329 | 330 | you can simply declare the `abTestVersion` directive once, separating versions with a comma: 331 | 332 | ```html 333 | 334 | 335 | 336 | ``` 337 | 338 | Versions should be separated by comma without spaces; however don't worry too much about mistyping, because if you accidentally add a space AngularAbTests will realise one of your versions doesn't match any of the declared ones, and raise an exception. 339 | 340 | As I have already said, in the configuration you can specify that a version is always going to be shown to SEO crawlers; you can do this at directive level as well, forcing a specific block to be shown to crawlers, regardless of how you had set that test up: 341 | 342 | ```html 343 | 344 | 345 | 346 | ``` 347 | 348 | Even if version `v1` wasn't configured to be shown to SEO crawlers, this specific block will be shown to crawlers. 349 | 350 | Remember one thing: if you don't specify any `versionForCrawlers` in your configuration, nor add manually `forCrawlers` in any of your directives, this automatically implies that none of the versions will be rendered when a SEO crawler visits your page; but of course, if your website is an application accessible only via login, that doesn't need to worry about SEO, this would be perfectly fine. 351 | 352 | ### Manually read / set a specific version during runtime 353 | 354 | First, you need to inject the token `AbTestsService`: 355 | 356 | ```typescript 357 | import { Component } from '@angular/core'; 358 | import { AbTestsService } from 'angular-ab-tests'; 359 | 360 | @Component({ 361 | selector: '...', 362 | templateUrl: '...', 363 | }) 364 | export class MyComponent { 365 | constructor(private abTestsService: AbTestsService) { 366 | //... 367 | } 368 | } 369 | ``` 370 | 371 | You can then call the public methods `getVersion` and `setVersion`, specifying, as usual, the test scope, if you want to work on a specific test that is not the default one. 372 | 373 | ```typescript 374 | // This retrieves the version associated to default scope 375 | this.abTestsService.getVersion(); 376 | 377 | // This retrieves the version associated to a scope different by the default one, 378 | // in case you are running multiple tests 379 | this.abTestsService.getVersion('my-scope'); 380 | 381 | // This sets the version associated to default scope 382 | this.abTestsService.setVersion('xxx'); // It raises an exception if `xxx` is not whitelisted 383 | 384 | // This retrieves the version associated to a scope different by the default one, 385 | // in case you are running multiple tests 386 | this.abTestsService.setVersion('xxx', 'my-scope'); 387 | ``` 388 | 389 | **IMPORTANT: when you use `setVersion`, the version only changes for pieces of HTML that have not been rendered yet**. 390 | 391 | This behaviour is logical: for whatever reason you are changing manually the version of a test, you don't want the change to apply to parts of the page that are already rendered, because the user would see a weird flickering. 392 | 393 | So, if you want to force a version, please ensure that you do it BEFORE rendering any HTML block affected by that test: otherwise, the user might end up seeing, in the same page, HTML blocks corresponding to different versions. 394 | 395 | 396 | ### Debugging cookies 397 | 398 | I strongly suggest you to use the Chrome extension called [EditThisCookie](https://chrome.google.com/webstore/detail/editthiscookie/fngmhnnpilhplaeedifhccceomclgfbg?hl=en). 399 | 400 | 401 | # Documentation 3: Tips 402 | 403 | ### 1 - Apply the directive on a ngContainer 404 | 405 | The HTML tag `` is an empty tag that is not rendered, it's used only to wrap a portion of HTML inside a structural directive. I suggest using this tag to associate a portion of HTML to an AB test version: 406 | 407 | ```html 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 |
416 | 417 | 418 |
419 | 420 | 421 | 422 | 423 | ``` 424 | 425 | ### 2 - Pass only static values to the directive 426 | 427 | As already mentioned, change detection is disabled for anything contained in a version that is not rendered, so you never risk a performance issue. However, change detection is still enabled on the wrapping directive, so in order to reduce computation to the minimum possible you should pass only static values to the directive: 428 | 429 | ```html 430 | 431 |
432 | 433 |
434 | 435 | 436 |
437 | 438 |
439 | ``` 440 | 441 | ### 3 - Better to avoid nested directives 442 | 443 | You should keep your code logic clean and easy to debug: be careful not to nest calls to directives associated to the same test, otherwise you might get unpredictable behaviours: 444 | 445 | ```html 446 | 447 | 448 | 449 | 450 | 451 | 452 | ``` 453 | 454 | In theory, if you nest directives associated to different tests, you are not doing anything wrong; however, there is a high chance that if you are doing that the statistical results of your tests [will clash](#4---ensure-your-tests-are-statistically-consistent). 455 | 456 | ```html 457 | 458 | 459 | 460 | 461 | 462 | 463 | ``` 464 | 465 | **So, better not to nest two directives of type `abTestVersion`.** 466 | 467 | How to be sure that you are not nesting two directives? Unfortunately with the decomposition of an HTML page into Angular components there is no definitive way of ensuring this, you'll have to organize your code smartly. 468 | 469 | 470 | ### 4 - Ensure your tests are statistically consistent 471 | 472 | Be careful not to produce a false positive by running two AB tests in the same time: there are many blogs covering this topic, for instance one I found is [this](https://conversionxl.com/blog/can-you-run-multiple-ab-tests-at-the-same-time). 473 | 474 | 475 | # Server Side Rendering 476 | 477 | There are a couple of ways to make this module work in SSR (thanks [@alirezamirian](https://github.com/alirezamirian) for this addition): 478 | 479 | 480 | ## 1 - Using `AbTestsServerModule` 481 | 482 | The module `AbTestsServerModule` is an extension that overrides the services used to handle userAgent and cookies: it replaces browser entities such as `document` and `window` with SSR-friendly services that do the same thing. 483 | 484 | You need to import it in your `app.server.module.ts`, in addition to the other imports discussed in the previous points. 485 | 486 | 487 | ```typescript 488 | import { NgModule } from '@angular/core'; 489 | import { AbTestsServerModule } from 'angular-ab-tests'; 490 | 491 | @NgModule({ 492 | imports:[ 493 | // ... 494 | AbTestsServerModule 495 | ] 496 | }) 497 | export class AppServerModule {} 498 | ``` 499 | 500 | `AbTestServerModule` optionally depends on `REQUEST` from [@nguniversal/express-engine](https://www.npmjs.com/package/@nguniversal/express-engine) and `CookiesService` from [@ngx-utils/cookies](https://www.npmjs.com/package/@ngx-utils/cookies) for detecting crawlers and manipulating cookies. 501 | 502 | Note that, even if they are not provided, both modules should be installed. 503 | 504 | 505 | ## 2 - Providing necessary services 506 | 507 | Alternatively, you can also provide `CookieHandler` and `CrawlerDetector` services yourself in your server module: 508 | 509 | ```typescript 510 | import { NgModule } from '@angular/core'; 511 | import { CrawlerDetector, CookieHandler } from 'angular-ab-tests'; 512 | import { MyOwnCrawlerDetector, MyOwnCookieHandler } from '...'; 513 | 514 | @NgModule({ 515 | providers: [ 516 | // ... 517 | { 518 | provide: CrawlerDetector, 519 | useClass: MyOwnCrawlerDetector, 520 | }, 521 | { 522 | provide: CookieHandler, 523 | useClass: MyOwnCookieHandler, 524 | }, 525 | ], 526 | }) 527 | export class AppServerModule {} 528 | ``` 529 | 530 | The class `MyOwnCrawlerDetector` must expose a method `isCrawler(): boolean`, that, as the name suggests, returns true if the page is being visited by a crawler. 531 | 532 | The class `MyOwnCookieHandler` must expose methods `get` and `set`, with the following interfaces: 533 | 534 | ```typescript 535 | get(name: string): string 536 | set(name: string, value: string, domain?: string, expires?: number): void 537 | ``` 538 | 539 | Obviously, in your own implementation of `MyOwnCookieHandler`, you don't have to worry about where the parameters `name`, `domain`, etc come from: you just need to add them to the cookie, if they are present, with whatever cookie-writing interface you are using. 540 | --------------------------------------------------------------------------------