├── .nvmrc ├── tests ├── styleMock.js ├── svgMock.js ├── config.spec.ts ├── query.spec.ts └── api.spec.ts ├── src ├── alwaysPromise.ts ├── condition.ts ├── ui.ts ├── behavioralSubject.ts ├── inMemoryPersister.ts ├── @types │ └── querystringify.d.ts ├── ui.prod.ts ├── query.ts ├── userSession.ts ├── userSessionPersister.ts ├── config.ts ├── index.ts ├── tracking.ts ├── styles │ └── ui.css ├── userAgentInfo.ts ├── main.ts ├── ui.dev.ts └── splitTest.ts ├── .editorconfig ├── config ├── jest.config.js ├── webpack.prod.js └── webpack.dev.js ├── tsconfig.json ├── tslint.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── CHANGELOG.md ├── README.md ├── examples └── index.html └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /tests/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /tests/svgMock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = 'div'; 3 | -------------------------------------------------------------------------------- /src/alwaysPromise.ts: -------------------------------------------------------------------------------- 1 | export async function alwaysPromise(thing: T | Promise) { 2 | return Promise.resolve(thing); 3 | } 4 | -------------------------------------------------------------------------------- /src/condition.ts: -------------------------------------------------------------------------------- 1 | import { UserAgentInfo } from './userAgentInfo'; 2 | 3 | export type Condition = (userAgentInfo: UserAgentInfo) => boolean | Promise; 4 | -------------------------------------------------------------------------------- /src/ui.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line 2 | const ui = process.env.NODE_ENV === 'production' ? require('./ui.prod') : require('./ui.dev'); 3 | 4 | export const uiFactory = ui.uiFactory; 5 | -------------------------------------------------------------------------------- /.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 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.ts] 13 | indent_size = 4 14 | 15 | [*.md] 16 | max_line_length = 0 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /config/jest.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | testEnvironment: "jsdom", 5 | collectCoverage: true, 6 | preset: 'ts-jest', 7 | rootDir: path.resolve(__dirname, '..'), 8 | moduleNameMapper: { 9 | '\\.(css|less|sass|scss)$': '/tests/styleMock.js', 10 | "\\.(svg)$": "/tests/svgMock.js" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": false, 4 | "lib": [ 5 | "es2015", 6 | "dom" 7 | ], 8 | "target": "ES5", 9 | "module": "commonjs", 10 | "declaration": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "outDir": "lib" 14 | }, 15 | "include": [ 16 | "./src/**/*.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended" 4 | ], 5 | "rules": { 6 | "no-console": [true, "time", "timeEnd", "trace"], 7 | "max-line-length": [true, 120], 8 | "quotemark": [true, "single", "avoid-escape"], 9 | "interface-name": [true, "never-prefix"], 10 | "variable-name": [true, "allow-leading-underscore", "ban-keywords", "check-format"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/behavioralSubject.ts: -------------------------------------------------------------------------------- 1 | export class BehavioralSubject { 2 | private subscribers: Array<(value: A) => void> = []; 3 | private _value: A; 4 | 5 | constructor(value: A) { 6 | this._value = value; 7 | } 8 | 9 | public next(value: A) { 10 | this._value = value; 11 | this.subscribers.forEach((observer) => observer(this._value)); 12 | } 13 | 14 | public subscribe(observer: (value: A) => void) { 15 | observer(this._value); 16 | this.subscribers.push(observer); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/inMemoryPersister.ts: -------------------------------------------------------------------------------- 1 | import { UserSessionPersister } from './userSessionPersister'; 2 | 3 | export class InMemoryPersister implements UserSessionPersister { 4 | private endOfLife: number = 0; 5 | private storage: string = ''; 6 | public loadUserSession() { 7 | return !this.endOfLife || (Date.now() > this.endOfLife) ? '' : this.storage; 8 | } 9 | public saveUserSession(userSession: string, daysToLive: number) { 10 | this.storage = userSession; 11 | this.endOfLife = Date.now() + (daysToLive * 24 * 60 * 60 * 1000); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | permissions: 4 | contents: write 5 | issues: write 6 | pull-requests: write 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | - run: npm ci 15 | - run: npm run lint 16 | - run: npm run test 17 | - run: npm run build 18 | - run: npx semantic-release 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 22 | -------------------------------------------------------------------------------- /src/@types/querystringify.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'querystringify' { 2 | /** 3 | * Simple query string parser. 4 | * @param query The query string that needs to be parsed. 5 | */ 6 | export function parse(query: string): { [key: string]: string; }; 7 | 8 | /** 9 | * Transform an object to a query string. 10 | * @param obj Object that should be transformed. 11 | * @param prefix Optional prefix. Default prefix is '?' when passing true. Pass a string to use a custom prefix. 12 | */ 13 | export function stringify(obj: object, prefix?: boolean | string): string; 14 | } 15 | -------------------------------------------------------------------------------- /src/ui.prod.ts: -------------------------------------------------------------------------------- 1 | import { BehavioralSubject } from './behavioralSubject'; 2 | import { SplitTest } from './splitTest'; 3 | import { UserAgentInfo } from './userAgentInfo'; 4 | 5 | export const uiFactory = ( 6 | tests: BehavioralSubject, 7 | reset: () => void, 8 | getCurrentTestVariation: (testName: string) => string, 9 | getUserAgentInfo: () => UserAgentInfo, 10 | setCurrentTestVariation: (testName: string, variation: string) => void, 11 | ) => { 12 | function show() { 13 | console.log('[Skift] UI is disabled'); 14 | } 15 | 16 | function hide() { 17 | console.log('[Skift] UI is disabled'); 18 | } 19 | 20 | return { 21 | hide, 22 | show, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import * as qs from 'querystringify'; 2 | 3 | export function removeAbTestParameter(search: string) { 4 | const query = qs.parse(search); 5 | delete query.abtest; 6 | return qs.stringify(query); 7 | } 8 | 9 | export function getAbTestParameter(search: string) { 10 | const query = qs.parse(search); 11 | 12 | if (!query) { 13 | return null; 14 | } 15 | 16 | const abTest = query.abtest; 17 | 18 | if (!abTest || typeof abTest !== 'string') { 19 | return null; 20 | } 21 | 22 | return abTest; 23 | } 24 | 25 | export function setAbTestParameter(search: string, abTest: string) { 26 | const query = qs.parse(search); 27 | query.abtest = abTest; 28 | return qs.stringify(query, true); 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | #Rollup.js 40 | .rpt2_cache 41 | 42 | temp 43 | dist 44 | types 45 | lib 46 | -------------------------------------------------------------------------------- /tests/config.spec.ts: -------------------------------------------------------------------------------- 1 | import config from '../src/config'; 2 | import skift from '../src/index'; 3 | 4 | describe('Config', () => { 5 | it('should get default configuration', () => { 6 | expect(config).toBeDefined(); 7 | expect(config.cookieName).toBeDefined(); 8 | expect(config.globalCondition).toBeDefined(); 9 | expect(config.sessionPersister).toBeDefined(); 10 | expect(config.tracking).toBeDefined(); 11 | expect(config.uiCondition).toBeDefined(); 12 | expect(config.userSessionDaysToLive).toBeDefined(); 13 | }); 14 | 15 | it('should override default config', () => { 16 | skift.config({ 17 | cookieName: 'Test', 18 | }); 19 | 20 | expect(config).toBeDefined(); 21 | expect(config.cookieName).toEqual('Test'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | Thanks for for the interest in contributing to Skift! 4 | 5 | Please read this document to learn how to work this project. 6 | 7 | ## Commiting new code 8 | 9 | Skift is released automatically with [semantic-release.](https://github.com/semantic-release/semantic-release) 10 | 11 | We use [Commitizen](https://github.com/commitizen/cz-cli) to ensure the quality of commit messages, please provide the maximum amount of details about your commit. 12 | 13 | To commit your code run: 14 | 15 | ```bash 16 | npm run cz 17 | ``` 18 | 19 | ## Running Skift it test mode 20 | 21 | When developing, it's preferable to run the project in test mode: 22 | 23 | ``` 24 | npm test 25 | ``` 26 | 27 | ## Building a distribution for the new release 28 | 29 | The following command will produce the development and minified versions of Skift distribution in `dist/` folder. 30 | 31 | ``` 32 | npm run build:prod 33 | ``` 34 | -------------------------------------------------------------------------------- /config/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | const TerserPlugin = require('terser-webpack-plugin'); 5 | 6 | module.exports = { 7 | devtool: 'source-map', 8 | mode: 'production', 9 | entry: { 10 | skift: './lib/index.js' 11 | }, 12 | output: { 13 | path: path.resolve(__dirname, '../dist'), 14 | filename: '[name].min.js', 15 | sourceMapFilename: '[name].source.map', 16 | library: 'skift', 17 | libraryTarget: 'umd' 18 | }, 19 | resolve: { 20 | extensions: ['.ts', '.js'], 21 | }, 22 | module: { 23 | rules: [{ 24 | test: /\.css$/, 25 | use: [{ 26 | loader: 'css-to-string-loader' 27 | }, { 28 | loader: 'css-loader', 29 | options: { 30 | minimize: true 31 | } 32 | }] 33 | }, { 34 | test: /\.svg$/, 35 | loader: 'file-loader', 36 | }] 37 | }, 38 | optimization: { 39 | minimize: true, 40 | minimizer: [new TerserPlugin()], 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /config/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | 6 | module.exports = { 7 | mode: 'development', 8 | entry: { 9 | skift: './src/index.ts' 10 | }, 11 | devtool: 'source-map', 12 | output: { 13 | path: path.resolve(__dirname, '../dist'), 14 | filename: '[name].js', 15 | sourceMapFilename: '[name].source.map', 16 | library: 'skift', 17 | libraryTarget: 'umd' 18 | }, 19 | resolve: { 20 | extensions: ['.ts', '.js'], 21 | }, 22 | module: { 23 | rules: [{ 24 | test: /\.ts$/, 25 | use: [{ 26 | loader: 'ts-loader' 27 | }], 28 | exclude: [/\.e2e\.ts$/] 29 | }, { 30 | test: /\.css$/, 31 | use: [{ 32 | loader: 'to-string-loader' 33 | }, { 34 | loader: 'css-loader' 35 | }] 36 | }, { 37 | test: /\.svg$/, 38 | loader: 'file-loader', 39 | }] 40 | }, 41 | plugins: [ 42 | new HtmlWebpackPlugin({ 43 | template: 'examples/index.html', 44 | }) 45 | ], 46 | }; 47 | -------------------------------------------------------------------------------- /src/userSession.ts: -------------------------------------------------------------------------------- 1 | import config from './config'; 2 | 3 | interface TestVariationsMap { 4 | [key: string]: string; 5 | } 6 | 7 | export class UserSession { 8 | public setTestVariation(testName: string, variationName: string): void { 9 | const variationsMap = this.loadVariations(); 10 | variationsMap[testName] = variationName; 11 | this.saveVariations(variationsMap); 12 | } 13 | 14 | public getTestVariation(testName: string): string { 15 | const variationsMap = this.loadVariations(); 16 | return variationsMap[testName]; 17 | } 18 | 19 | public reset() { 20 | this.saveVariations({}); 21 | } 22 | 23 | private saveVariations(variationsMap: TestVariationsMap) { 24 | config.sessionPersister.saveUserSession(JSON.stringify(variationsMap), config.userSessionDaysToLive); 25 | } 26 | 27 | private loadVariations(): TestVariationsMap { 28 | const variationsMap: TestVariationsMap = JSON.parse(config.sessionPersister.loadUserSession() || '{}'); 29 | return variationsMap; 30 | } 31 | } 32 | 33 | export default new UserSession(); 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Trustpilot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/userSessionPersister.ts: -------------------------------------------------------------------------------- 1 | import config from './config'; 2 | export interface UserSessionPersister { 3 | loadUserSession(): string | null; 4 | saveUserSession(userSession: string, daysToLive: number): void; 5 | } 6 | export class CookiePersister implements UserSessionPersister { 7 | private static createCookie(name: string, value: string, days: number): void { 8 | let expires = ''; 9 | if (days) { 10 | const date = new Date(); 11 | date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); 12 | expires = '; expires=' + date.toUTCString(); 13 | } 14 | document.cookie = name + '=' + value + expires + '; path=/'; 15 | } 16 | private static readCookie(name: string): string | null { 17 | const nameEq = name + '='; 18 | const ca = document.cookie.split(';'); 19 | for (let c of ca) { 20 | while (c.charAt(0) === ' ') { 21 | c = c.substring(1, c.length); 22 | } 23 | if (c.indexOf(nameEq) === 0) { 24 | return c.substring(nameEq.length, c.length); 25 | } 26 | } 27 | return null; 28 | } 29 | public loadUserSession() { 30 | return CookiePersister.readCookie(config.cookieName); 31 | } 32 | public saveUserSession(userSession: string, daysToLive: number) { 33 | CookiePersister.createCookie(config.cookieName, userSession, daysToLive); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { Condition } from './condition'; 2 | import { removeAbTestParameter } from './query'; 3 | import { ConsoleTracking, Tracking } from './tracking'; 4 | import { CookiePersister } from './userSessionPersister'; 5 | import type { UserSessionPersister } from './userSessionPersister'; 6 | 7 | export interface Config { 8 | cookieName: string; 9 | globalCondition: Condition; 10 | /** 11 | * Function called everytime the user trigger a variation change. 12 | * This function is not called on initial setup but is called with no paramaters on reset. 13 | * @param testName Name of the split test 14 | * @param variationName Name of the new variation 15 | */ 16 | onVariationChange: (testName?: string, variationName?: string) => void; 17 | sessionPersister: UserSessionPersister; 18 | tracking: Tracking; 19 | uiCondition: Condition; 20 | userSessionDaysToLive: number; 21 | } 22 | 23 | const config: Config = { 24 | cookieName: 'skiftABTest', 25 | globalCondition: () => true, 26 | onVariationChange: () => { 27 | const previousSearch = location.search; 28 | const newSearch = removeAbTestParameter(location.search); 29 | 30 | if (previousSearch !== newSearch) { 31 | location.search = newSearch; 32 | } else { 33 | location.reload(); 34 | } 35 | }, 36 | sessionPersister: new CookiePersister(), 37 | tracking: new ConsoleTracking(), 38 | uiCondition: () => false, 39 | userSessionDaysToLive: 3, 40 | }; 41 | 42 | export default config; 43 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryPersister } from './inMemoryPersister'; 2 | import { 3 | config, 4 | create, 5 | getCurrentTestVariation, 6 | getTest, 7 | getUserAgentInfo, 8 | reset, 9 | setCurrentTestVariation, 10 | shouldShowUI, 11 | tests, 12 | testsObservable, 13 | } from './main'; 14 | import { SplitTest } from './splitTest'; 15 | import { uiFactory } from './ui'; 16 | import { CookiePersister } from './userSessionPersister'; 17 | import type { UserSessionPersister } from './userSessionPersister'; 18 | 19 | const ui = uiFactory( 20 | testsObservable, 21 | reset, 22 | getCurrentTestVariation, 23 | getUserAgentInfo, 24 | setCurrentTestVariation, 25 | ); 26 | 27 | function domReady(cb: () => void) { 28 | if (document.readyState !== 'loading') { 29 | return cb(); 30 | } 31 | document.addEventListener('DOMContentLoaded', cb); 32 | } 33 | 34 | domReady(() => { 35 | setTimeout(async () => { 36 | if (await shouldShowUI()) { 37 | ui.show(); 38 | } 39 | }, 0); 40 | }); 41 | 42 | export { 43 | tests, 44 | config, 45 | getUserAgentInfo, 46 | getTest, 47 | create, 48 | getCurrentTestVariation, 49 | setCurrentTestVariation, 50 | reset, 51 | ui, 52 | SplitTest, 53 | UserSessionPersister, 54 | InMemoryPersister, 55 | CookiePersister, 56 | }; 57 | 58 | export default { 59 | SplitTest, 60 | config, 61 | create, 62 | getCurrentTestVariation, 63 | getTest, 64 | getUserAgentInfo, 65 | reset, 66 | setCurrentTestVariation, 67 | tests, 68 | ui, 69 | }; 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [4.4.0](https://github.com/trustpilot/skift/compare/v4.3.1...v4.4.0) (2024-06-04) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * remove configLoaded in favour of using cookie name / clean up svgs ([febad26](https://github.com/trustpilot/skift/commit/febad263268bcfd851d4a6d579a1e09a60ecbe7c)) 7 | 8 | 9 | ### Features 10 | 11 | * fix abtestui overlay ([f0abdf2](https://github.com/trustpilot/skift/commit/f0abdf242a6a958cd24e70afe596bd00920877d8)) 12 | 13 | ## [4.3.1](https://github.com/trustpilot/skift/compare/v4.3.0...v4.3.1) (2022-11-24) 14 | 15 | 16 | ### Bug Fixes 17 | 18 | * ui buttons ([acd74d4](https://github.com/trustpilot/skift/commit/acd74d4504465308eb30d13543ddc7c82291dd72)) 19 | 20 | # [4.3.0](https://github.com/trustpilot/skift/compare/v4.2.9...v4.3.0) (2020-09-03) 21 | 22 | 23 | ### Bug Fixes 24 | 25 | * linting ([4269867](https://github.com/trustpilot/skift/commit/4269867)) 26 | * tests ([79b3595](https://github.com/trustpilot/skift/commit/79b3595)) 27 | 28 | 29 | ### Features 30 | 31 | * add in memory storage ([d55e76e](https://github.com/trustpilot/skift/commit/d55e76e)) 32 | * add sessionPersister to config ([8b8651d](https://github.com/trustpilot/skift/commit/8b8651d)) 33 | 34 | ## [4.2.9](https://github.com/trustpilot/skift/compare/v4.2.8...v4.2.9) (2019-09-27) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * trigger release to fix deployment ([1fecd66](https://github.com/trustpilot/skift/commit/1fecd66)) 40 | 41 | ## [4.2.8](https://github.com/trustpilot/skift/compare/v4.2.7...v4.2.8) (2019-09-26) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * update semantic release configuration to publish package ([46faa49](https://github.com/trustpilot/skift/commit/46faa49)) 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # Skift [![npm version](https://badge.fury.io/js/skift.svg)](https://badge.fury.io/js/skift) [![Build Status](https://travis-ci.org/trustpilot/skift.svg?branch=master)](https://travis-ci.org/trustpilot/skift) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 6 | 7 | A/B Testing tool for the modern Web 8 | 9 | ## Usage 10 | 11 | ### Basic usage 12 | 13 | ```js 14 | import skift from 'skift'; 15 | 16 | // Configure Skift. 17 | skift.config({ 18 | tracking: { 19 | track: function(event, trackingData) { 20 | console.log('A/B test event: ' + event, trackingData); 21 | } 22 | } 23 | }); 24 | 25 | // Describe the A/B Test. 26 | skift 27 | .create('My awesome test') 28 | .setCondition(() => { 29 | return window.location.pathname === 'contacts' 30 | }) 31 | .addVariation({ 32 | name: 'A form with the new design', 33 | setup() { 34 | document.getElementById('form').addClass('visible') 35 | } 36 | }) 37 | .addVariation({ 38 | name: 'Control' 39 | }) 40 | .setup(); // Don't forget to setup the test! 41 | ``` 42 | 43 | ## New to A/B testing? 44 | 45 | We recommend using [Amplitude](https://amplitude.com/) for goal tracking. 46 | 47 | ## Contributing 48 | 49 | Interested in contributing? Please have a look at our [developer documentation](CONTRIBUTING.md) for more information on how to get started. 50 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Skift: A/B test basic example 6 | 20 | 21 | 22 | 23 | 24 | 25 |

Skift: A/B test basic example

26 |

This is an A/B split test

27 |

Below there's a div. It will be blue originally, and orange if you're in the A/B test.

28 | 29 | 30 | 31 | 32 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/tracking.ts: -------------------------------------------------------------------------------- 1 | export interface TrackingData { 2 | [key: string]: any; 3 | } 4 | 5 | /** 6 | * Describing a handler for A/B test events 7 | */ 8 | export interface Tracking { 9 | /** 10 | * Records an action your user performs. 11 | * @param event The name of the event you’re tracking. 12 | * @param trackingData A dictionary of properties for the event 13 | */ 14 | track(event: TrackEventType, trackingData: TrackingData): void; 15 | 16 | /** 17 | * A helper method that attaches the track call as a handler to a link 18 | * @param element DOM element to be bound with track method 19 | * @param event The name of the event, passed to the track method 20 | * @param trackingData A dictionary of properties to pass with the track method. 21 | */ 22 | trackLink(element: Element, event: TrackEventType, trackingData: TrackingData): void; 23 | } 24 | 25 | /** 26 | * A function that extends a tracking data object with even more data 27 | */ 28 | export type TrackingDataExtender = (trackingData: TrackingData, event: string) => TrackingData; 29 | 30 | export declare type TrackEventType = 'ExperimentViewed' | 'ExperimentActionPerformed'; 31 | export declare type TrackEventActionType = 'Click' | 'Type'; 32 | 33 | /** 34 | * Constructs a new TrackingDataExtender that extending the existing tracking data with the provided tracking data 35 | * @param newTrackingData 36 | */ 37 | export function trackingDataExtenderFactory(newTrackingData: TrackingData): TrackingDataExtender { 38 | return (trackingData: TrackingData) => ({ 39 | ...trackingData, 40 | ...newTrackingData, 41 | }); 42 | } 43 | 44 | export class ConsoleTracking implements Tracking { 45 | public track(event: TrackEventType, trackingData: TrackingData) { 46 | console.log('Split testing event: ' + event, trackingData); 47 | } 48 | 49 | public trackLink(element: Element, event: TrackEventType, trackingData: TrackingData) { 50 | element.addEventListener('click', () => { 51 | this.track(event, trackingData); 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/styles/ui.css: -------------------------------------------------------------------------------- 1 | .skift { 2 | position: fixed; 3 | bottom: 5px; 4 | right: 5px; 5 | width: 300px; 6 | max-height: 500px; 7 | color: #292929; 8 | z-index: 999; 9 | background-color: #FFFFFF; 10 | border: 2px solid #00b67a; 11 | transition: all .5s ease-out; 12 | opacity: 1; 13 | border-radius: 3px; 14 | overflow: auto; 15 | font-size: 12px; 16 | } 17 | 18 | .skift li.selected { 19 | font-weight: bold; 20 | } 21 | 22 | .skift .variations .legend { 23 | font-size: 12px; 24 | } 25 | 26 | .skift .header { 27 | top: 0; 28 | left: 0; 29 | padding: 8px; 30 | background-color: #00b67a; 31 | color: #FFFFFF; 32 | font-size: 16px; 33 | font-weight: bold; 34 | } 35 | 36 | .skift.hideme { 37 | bottom: -550px; 38 | opacity: 0; 39 | } 40 | 41 | .skift .variations { 42 | background: #f4f7f9; 43 | box-shadow: inset 0 0 1px 1px #dfe2e5; 44 | padding: 8px; 45 | } 46 | 47 | .skift .variations ul { 48 | margin: 0; 49 | } 50 | 51 | .skift .test { 52 | padding: 8px 8px 0 8px; 53 | } 54 | 55 | .skift .data-label { 56 | display: inline-block; 57 | margin-bottom: 2px; 58 | margin-right: 5px; 59 | color: #292929; 60 | } 61 | 62 | .skift .data-label:after { 63 | content: ":"; 64 | } 65 | 66 | .skift .data-value { 67 | color: #000; 68 | font-weight: bold; 69 | } 70 | 71 | .skift .close { 72 | position: absolute; 73 | right: 8px; 74 | background-color: #fff; 75 | color: #00b67a; 76 | height: 20px; 77 | width: 20px; 78 | border-radius: 50%; 79 | cursor: pointer; 80 | text-align: center; 81 | font-size: 16px; 82 | line-height: 20px 83 | } 84 | 85 | .skift .reset { 86 | width: 100%; 87 | padding: 5px 10px; 88 | background-color: #00b67a; 89 | font-size: 18px; 90 | color: #FFFFFF; 91 | border: 0; 92 | cursor: pointer; 93 | } 94 | 95 | .skift .icon { 96 | display: inline-block; 97 | cursor: pointer; 98 | color: #0c59f2; 99 | border: none; 100 | background-color: transparent; 101 | } 102 | 103 | .icon > svg { 104 | height: inherit; 105 | width: inherit; 106 | fill: currentColor; 107 | } 108 | 109 | .skift .icon:nth-child(2) { 110 | margin-left: 8px; 111 | } 112 | -------------------------------------------------------------------------------- /tests/query.spec.ts: -------------------------------------------------------------------------------- 1 | import * as query from '../src/query'; 2 | 3 | describe('Query', () => { 4 | describe('#removeAbTestParameter', () => { 5 | it('should remove abtest parameter if it exists', () => { 6 | const location = { 7 | search: '', 8 | }; 9 | 10 | location.search = '?abtest=test'; 11 | const newSearch = query.removeAbTestParameter(location.search); 12 | expect(newSearch).toEqual(''); 13 | }); 14 | 15 | it('should remove all abtest parameters if multiple are present', () => { 16 | const location = { 17 | search: '', 18 | }; 19 | 20 | location.search = '?abtest=test1&abtest=test2&abtest=3'; 21 | const newSearch = query.removeAbTestParameter(location.search); 22 | expect(newSearch).toEqual(''); 23 | }); 24 | }); 25 | 26 | describe('#getAbTestParameter', () => { 27 | it('should return null if there is no query', () => { 28 | const abtest = query.getAbTestParameter(location.search); 29 | expect(abtest).toBeNull(); 30 | }); 31 | 32 | it('should return null if there is a query but no abtest parameter', () => { 33 | const location = { 34 | search: 'notabtest=test', 35 | }; 36 | 37 | const abtest = query.getAbTestParameter(location.search); 38 | expect(abtest).toBeNull(); 39 | }); 40 | 41 | it('should return null if the abtest parameter is not a string', () => { 42 | const location = { 43 | search: 'abtest=', 44 | }; 45 | 46 | const abtest = query.getAbTestParameter(location.search); 47 | expect(abtest).toBeNull(); 48 | }); 49 | 50 | it('should return the abtest parameter if it exists', () => { 51 | const location = { 52 | search: 'abtest=test', 53 | }; 54 | 55 | const abtest = query.getAbTestParameter(location.search); 56 | expect(abtest).toEqual('test'); 57 | }); 58 | }); 59 | 60 | describe('#setAbTestParameter', () => { 61 | it('should set a new abTest parameter if it does not exist', () => { 62 | const location = { 63 | search: '', 64 | }; 65 | 66 | const newSearch = query.setAbTestParameter(location.search, 'test'); 67 | 68 | expect(newSearch).toEqual('?abtest=test'); 69 | }); 70 | 71 | it('should replace the abTest parameter if it already exists', () => { 72 | const location = { 73 | search: 'abtest=test1', 74 | }; 75 | 76 | const newSearch = query.setAbTestParameter(location.search, 'test2'); 77 | 78 | expect(newSearch).toEqual('?abtest=test2'); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skift", 3 | "description": "Split testing tool for the Web", 4 | "version": "4.4.0", 5 | "author": "Trustpilot A/S", 6 | "homepage": "https://github.com/trustpilot/skift#readme", 7 | "license": "MIT", 8 | "main": "lib/index.js", 9 | "types": "lib/index.d.ts", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/trustpilot/skift.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/trustpilot/skift/issues" 16 | }, 17 | "scripts": { 18 | "build": "npm run clean && npm run build:dev && npm run build:ts && npm run build:prod", 19 | "build:dev": "webpack-cli --config ./config/webpack.dev.js", 20 | "build:prod": "webpack-cli --config ./config/webpack.prod.js", 21 | "build:ts": "tsc --project . && copyup ./src/**/*.svg ./src/**/*.css lib", 22 | "clean": "rimraf lib/ dist/ types/ yarn-error.log npm-debug.log", 23 | "lint": "tslint 'src/**/*.ts' --project ./", 24 | "lint:fix": "tslint 'src/**/*.ts' --project ./ --fix", 25 | "start": "webpack-dev-server --config ./config/webpack.dev.js", 26 | "test": "jest --config ./config/jest.config.js", 27 | "ts": "tsc" 28 | }, 29 | "dependencies": { 30 | "querystringify": "^2.1.1" 31 | }, 32 | "devDependencies": { 33 | "@commitlint/cli": "^17.3.0", 34 | "@commitlint/config-conventional": "^17.3.0", 35 | "@semantic-release/changelog": "^6.0.1", 36 | "@semantic-release/git": "^10.0.1", 37 | "@types/jest": "^24.0.18", 38 | "@types/node": "^20.12.12", 39 | "ajv": "^6.10.2", 40 | "copyfiles": "^2.1.1", 41 | "css-loader": "^7.1.1", 42 | "file-loader": "^6.2.0", 43 | "html-webpack-plugin": "^5.6.0", 44 | "husky": "^3.0.5", 45 | "jest": "^29.1.2", 46 | "jest-environment-jsdom": "^29.7.0", 47 | "rimraf": "^3.0.0", 48 | "semantic-release": "^19.0.5", 49 | "terser-webpack-plugin": "^2.1.0", 50 | "ts-jest": "^29.1.2", 51 | "ts-loader": "^9.5.1", 52 | "tslint": "^5.20.0", 53 | "typescript": "^5.4.5", 54 | "webpack": "^5.91.0", 55 | "webpack-cli": "^5.1.4", 56 | "webpack-dev-server": "^5.0.4", 57 | "to-string-loader": "^1.2.0" 58 | }, 59 | "files": [ 60 | "dist", 61 | "src", 62 | "types", 63 | "lib", 64 | "images", 65 | "styles" 66 | ], 67 | "release": { 68 | "plugins": [ 69 | "@semantic-release/commit-analyzer", 70 | "@semantic-release/release-notes-generator", 71 | "@semantic-release/changelog", 72 | "@semantic-release/npm", 73 | [ 74 | "@semantic-release/git", 75 | { 76 | "assets": [ 77 | "package.json", 78 | "package-lock.json", 79 | "CHANGELOG.md" 80 | ], 81 | "message": "chore: release ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 82 | } 83 | ], 84 | "@semantic-release/github" 85 | ], 86 | "preset": "angular" 87 | }, 88 | "husky": { 89 | "hooks": { 90 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS --extends @commitlint/config-conventional" 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/userAgentInfo.ts: -------------------------------------------------------------------------------- 1 | function getNameAndVersion() { 2 | const ua = navigator.userAgent; 3 | let tem: RegExpMatchArray | [] | null; 4 | let match: RegExpMatchArray | [] = 5 | ua.match( 6 | /(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i, 7 | ) || []; 8 | if (/trident/i.test(match[1])) { 9 | tem = /\brv[ :]+(\d+)/g.exec(ua) || []; 10 | return { 11 | name: 'IE', 12 | version: tem[1] || '', 13 | }; 14 | } 15 | if (match[1] === 'Chrome') { 16 | tem = ua.match(/\bOPR\/(\d+)/); 17 | if (tem != null) { 18 | return { 19 | name: 'Opera', 20 | version: tem[1], 21 | }; 22 | } 23 | } 24 | match = match[2] 25 | ? [match[1], match[2]] 26 | : [navigator.appName, navigator.appVersion, '-?']; 27 | tem = ua.match(/version\/(\d+)/i); 28 | if (tem !== null) { 29 | match.splice(1, 1, tem[1]); 30 | } 31 | return { 32 | name: match[0], 33 | version: match[1], 34 | }; 35 | } 36 | 37 | function isMobile() { 38 | const ua = navigator.userAgent || navigator.vendor; 39 | // Disable the rule for now and consider using RegExp constructor with a string. 40 | // tslint:disable-next-line:max-line-length 41 | return ( 42 | /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( 43 | ua, 44 | ) || 45 | /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( 46 | ua.substr(0, 4), 47 | ) 48 | ); 49 | } 50 | 51 | export interface UserAgentInfo { 52 | name: string; 53 | version: string; 54 | isMobile: boolean; 55 | } 56 | 57 | export default function getUserAgentInfo(): UserAgentInfo { 58 | return { 59 | ...getNameAndVersion(), 60 | isMobile: isMobile(), 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /tests/api.spec.ts: -------------------------------------------------------------------------------- 1 | import skift from '../src/index'; 2 | import { SplitTest } from '../src/splitTest'; 3 | 4 | describe('Top-level api', () => { 5 | it('should export the object', () => { 6 | expect(typeof skift).toBe('object'); 7 | expect(skift).toBeDefined(); 8 | }); 9 | 10 | it('should be impossible to create an empty test', () => { 11 | skift.create('Awesome test!').setup() 12 | .then(() => expect('not').toBe('here')) 13 | .catch((err) => { 14 | expect(skift.getTest('Awesome test!')).toBeDefined(); 15 | expect(skift.getTest('Awesome test!') instanceof SplitTest).toBe(true); 16 | }); 17 | }); 18 | 19 | it('should be possible to get a variation', () => { 20 | const test = skift 21 | .create('Another test!') 22 | .addVariation({ name: 'Variation A' }); 23 | 24 | expect(test.getVariation('Variation A')).toBeDefined(); 25 | }); 26 | 27 | it('should be possible to setup a test with two variations and retrieve it by name', async () => { 28 | const test = skift 29 | .create('Another awesome test!') 30 | .addVariation({ name: 'Variation C' }) 31 | .addVariation({ name: 'Variation D' }); 32 | 33 | expect(await test.setup()).toBe(true); 34 | expect(skift.getTest('Another awesome test!')).toBeDefined(); 35 | expect(test === skift.getTest('Another awesome test!')).toBeTruthy(); 36 | }); 37 | 38 | it('should be possible to show the UI', () => { 39 | const testName = 'The test to check out!'; 40 | skift 41 | .create(testName) 42 | .addVariation({ name: 'Variation A' }) 43 | .addVariation({ name: 'Variation B' }) 44 | .setup(); 45 | 46 | skift.ui.show(); 47 | 48 | const skiftUI = document.querySelector('.skift'); 49 | expect(skiftUI).toBeTruthy(); 50 | }); 51 | 52 | describe('when setting a condition', () => { 53 | it('allows for a promise', async () => { 54 | expect(await skift 55 | .create('testing conditions') 56 | .setCondition(() => Promise.resolve(true)) 57 | .addVariation({ name: 'A'}) 58 | .setup()).toBe(true); 59 | }); 60 | 61 | it('allows for a boolean', async () => { 62 | expect(await skift 63 | .create('testing conditions') 64 | .setCondition(() => true) 65 | .addVariation({ name: 'A'}) 66 | .setup()).toBe(true); 67 | }); 68 | }); 69 | 70 | describe('when checking for initialization', () => { 71 | it('resolves to true when setup is called, completes, and was successful', async () => { 72 | const test = skift 73 | .create('testing conditions') 74 | .setCondition(() => true) 75 | .addVariation({ name: 'A'}); 76 | 77 | const setupPromise = test.setup(); 78 | const initializedPromise = test.isInitialized(); 79 | 80 | await setupPromise; 81 | expect(await initializedPromise).toBe(true); 82 | }); 83 | 84 | it('resolves to false when setup is called, completes, and was canceled', async () => { 85 | const test = skift 86 | .create('testing conditions') 87 | .setCondition(() => false) 88 | .addVariation({ name: 'A'}); 89 | 90 | const setupPromise = test.setup(); 91 | const initializedPromise = test.isInitialized(); 92 | 93 | await setupPromise; 94 | expect(await initializedPromise).toBe(false); 95 | }); 96 | 97 | it('resolves to false when setup is never called', async () => { 98 | const test = skift 99 | .create('testing conditions') 100 | .setCondition(() => false) 101 | .addVariation({ name: 'A'}); 102 | 103 | const initializedPromise = test.isInitialized(); 104 | 105 | expect(await initializedPromise).toBe(false); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { alwaysPromise } from './alwaysPromise'; 2 | import { BehavioralSubject } from './behavioralSubject'; 3 | import _config, { Config } from './config'; 4 | import { getAbTestParameter } from './query'; 5 | import { SplitTest } from './splitTest'; 6 | import { TrackingDataExtender, trackingDataExtenderFactory } from './tracking'; 7 | import _getUserAgentInfo from './userAgentInfo'; 8 | import userSession, { UserSession } from './userSession'; 9 | 10 | const userAgentInfo = _getUserAgentInfo(); 11 | export const tests: SplitTest[] = []; 12 | export const testsObservable: BehavioralSubject = new BehavioralSubject(tests); 13 | 14 | export function config(userConfig: Partial = {}) { 15 | if (userConfig.cookieName) { 16 | _config.cookieName = userConfig.cookieName; 17 | } 18 | if (userConfig.globalCondition) { 19 | _config.globalCondition = userConfig.globalCondition; 20 | } 21 | if (userConfig.tracking) { 22 | _config.tracking = userConfig.tracking; 23 | } 24 | if (userConfig.uiCondition) { 25 | _config.uiCondition = userConfig.uiCondition; 26 | } 27 | if (userConfig.userSessionDaysToLive) { 28 | _config.userSessionDaysToLive = userConfig.userSessionDaysToLive; 29 | } 30 | if (userConfig.onVariationChange) { 31 | _config.onVariationChange = userConfig.onVariationChange; 32 | } 33 | if (userConfig.sessionPersister) { 34 | const session = _config.sessionPersister.loadUserSession() || ''; 35 | _config.sessionPersister = userConfig.sessionPersister; 36 | _config.sessionPersister.saveUserSession( 37 | session, 38 | _config.userSessionDaysToLive, 39 | ); 40 | } 41 | } 42 | 43 | /** 44 | * The base tracking data extender supplying general tracking data 45 | */ 46 | function baseTrackingDataExtenderFactory(): TrackingDataExtender { 47 | return trackingDataExtenderFactory({ 48 | browser: userAgentInfo.name, 49 | browserVersion: userAgentInfo.version, 50 | isMobile: userAgentInfo.isMobile, 51 | }); 52 | } 53 | 54 | function initializeFromQueryString(session: UserSession): void { 55 | const abTest = getAbTestParameter(location.search); 56 | 57 | if (abTest) { 58 | try { 59 | const [test, variant] = atob(abTest).split('='); 60 | session.setTestVariation(test, variant); 61 | } catch (e) { 62 | // TODO: Handle error. 63 | } 64 | } 65 | } 66 | 67 | export function initialize(): void { 68 | initializeFromQueryString(userSession); 69 | } 70 | 71 | // Public API 72 | 73 | async function validateInitialized(test: SplitTest) { 74 | if (!await test.isInitialized()) { 75 | throw new Error(`Skift: Test "${test.name}" is not initialized yet!`); 76 | } 77 | } 78 | function validateTestName(testName: string) { 79 | if (!getTest(testName)) { 80 | throw new Error(`Skift: Unknown test "${testName}"`); 81 | } 82 | } 83 | 84 | export function getUserAgentInfo() { 85 | return userAgentInfo; 86 | } 87 | 88 | export function getTest(name: string) { 89 | return tests.filter((t) => t.name === name)[0]; 90 | } 91 | 92 | export function create(name: string): SplitTest { 93 | const test = new SplitTest( 94 | name, 95 | userAgentInfo, 96 | baseTrackingDataExtenderFactory(), 97 | ); 98 | 99 | // Initialize test from query params if available 100 | initialize(); 101 | 102 | tests.push(test); 103 | test.changes.subscribe(() => testsObservable.next(tests)); 104 | return test; 105 | } 106 | 107 | export function getCurrentTestVariation(testName: string): string { 108 | validateTestName(testName); 109 | validateInitialized(getTest(testName)); 110 | return userSession.getTestVariation(testName); 111 | } 112 | 113 | export function setCurrentTestVariation( 114 | testName: string, 115 | variation: string, 116 | ): void { 117 | validateTestName(testName); 118 | validateInitialized(getTest(testName)); 119 | 120 | userSession.setTestVariation(testName, variation); 121 | _config.onVariationChange(testName, variation); 122 | } 123 | 124 | export function reset(): void { 125 | userSession.reset(); 126 | _config.onVariationChange(); 127 | } 128 | 129 | // auto resolves the promise after 2 seconds if the condition is not met - stops a hanging promise 130 | const waitUntil = (condition: () => any, checkInterval = 100, timeout = 2000) => { 131 | return new Promise((resolve, reject) => { 132 | const startTime = Date.now(); 133 | const interval = setInterval(() => { 134 | if (condition()) { 135 | clearInterval(interval); 136 | clearTimeout(safetyTimeout); 137 | resolve(); 138 | } else if (Date.now() - startTime >= timeout) { 139 | clearInterval(interval); 140 | clearTimeout(safetyTimeout); 141 | resolve(); 142 | } 143 | }, checkInterval); 144 | 145 | const safetyTimeout = setTimeout(() => { 146 | clearInterval(interval); 147 | resolve(); 148 | }, timeout); 149 | }); 150 | }; 151 | 152 | export async function shouldShowUI() { 153 | // cookie name is provided from split test package user config. 154 | // Ensures it's loaded before calling the shouldShowUI function 155 | await waitUntil(() => _config.cookieName === 'trustpilotABTest'); 156 | const promises = [ 157 | _config.globalCondition(userAgentInfo), 158 | _config.uiCondition(userAgentInfo), 159 | ].map(alwaysPromise); 160 | 161 | return (await Promise.all(promises)).every((a) => a); 162 | } 163 | -------------------------------------------------------------------------------- /src/ui.dev.ts: -------------------------------------------------------------------------------- 1 | import { BehavioralSubject } from './behavioralSubject'; 2 | import { InternalVariation, SplitTest } from './splitTest'; 3 | import { UserAgentInfo } from './userAgentInfo'; 4 | 5 | declare const require: any; 6 | 7 | function getVariationPercentage(variation: InternalVariation): string { 8 | return Math.round(variation.normalizedWeight * 100) + '%'; 9 | } 10 | 11 | let isInitialized = false; 12 | let skift: Element; 13 | 14 | export const uiFactory = ( 15 | tests: BehavioralSubject, 16 | reset: () => void, 17 | getCurrentTestVariation: (testName: string) => string, 18 | getUserAgentInfo: () => UserAgentInfo, 19 | setCurrentTestVariation: (testName: string, variation: string) => void, 20 | ) => { 21 | function renderButton(svgContent: string, onClick: () => void) { 22 | const button = document.createElement('button'); 23 | button.className = 'icon'; 24 | button.innerHTML = svgContent; 25 | button.addEventListener('click', onClick); 26 | return button; 27 | } 28 | 29 | function renderLink(splitTest: SplitTest, variation: InternalVariation) { 30 | return renderButton('currently active', () => { 31 | const button = document.createElement('button'); 32 | button.value = splitTest.getVariationUrl(variation.name); 33 | document.body.appendChild(button); 34 | document.execCommand('copy'); 35 | document.body.removeChild(button); 36 | }); 37 | } 38 | 39 | function renderSelectedVaraition( 40 | splitTest: SplitTest, 41 | variation: InternalVariation, 42 | ) { 43 | const item = document.createElement('li'); 44 | item.className = 'selected'; 45 | item.textContent = variation.name; 46 | 47 | const link = renderLink(splitTest, variation); 48 | 49 | item.appendChild(link); 50 | return item; 51 | } 52 | 53 | function renderUnselectedVariation( 54 | splitTest: SplitTest, 55 | variation: InternalVariation, 56 | ) { 57 | const item = document.createElement('li'); 58 | item.textContent = variation.name; 59 | const open = renderButton('change to this variant', () => { 60 | setCurrentTestVariation(splitTest.name, variation.name); 61 | }); 62 | item.appendChild(open); 63 | return item; 64 | } 65 | 66 | async function renderTest(splitTest: SplitTest) { 67 | if (await splitTest.isInitialized()) { 68 | const currentVariation = splitTest.getVariation( 69 | getCurrentTestVariation(splitTest.name), 70 | ); 71 | 72 | const data: { [key: string]: any } = { 73 | Test: splitTest.name, 74 | Variation: `${currentVariation.name} (${getVariationPercentage( 75 | currentVariation, 76 | )})`, 77 | }; 78 | 79 | const test = document.createElement('div'); 80 | test.className = 'test'; 81 | test.innerHTML = ` 82 | ${Object.keys(data) 83 | .map( 84 | (key) => ` 85 |
86 | ${key} 87 | ${data[key]} 88 |
89 | `, 90 | ) 91 | .join('')} 92 | `; 93 | 94 | const variations = document.createElement('div'); 95 | variations.className = 'variations'; 96 | 97 | const legend = document.createElement('span'); 98 | legend.className = 'legend'; 99 | legend.textContent = 'Variations available:'; 100 | 101 | const list = document.createElement('ul'); 102 | splitTest.variations.forEach((variation) => { 103 | if (currentVariation.name === variation.name) { 104 | return list.appendChild( 105 | renderSelectedVaraition(splitTest, variation), 106 | ); 107 | } else { 108 | return list.appendChild( 109 | renderUnselectedVariation(splitTest, variation), 110 | ); 111 | } 112 | }); 113 | 114 | variations.appendChild(legend); 115 | variations.appendChild(list); 116 | 117 | return [test, variations]; 118 | } 119 | } 120 | 121 | function showSplitTestUi() { 122 | const previousContainer = document.querySelector('.skift'); 123 | 124 | if (previousContainer) { 125 | skift = previousContainer; 126 | } else { 127 | skift = document.createElement('div'); 128 | skift.className = 'skift'; 129 | } 130 | 131 | const style = document.createElement('style'); 132 | style.innerHTML = require('./styles/ui.css'); 133 | 134 | const header = document.createElement('div'); 135 | header.className = 'header'; 136 | header.textContent = 'Skift'; 137 | 138 | const testList = document.createElement('div'); 139 | testList.className = 'tests'; 140 | 141 | tests.subscribe(async (list) => { 142 | while (testList.hasChildNodes()) { 143 | testList.removeChild(testList.lastChild as Node); 144 | } 145 | const test = await list 146 | .map(renderTest) 147 | .reduce((promise, futureElement) => { 148 | return promise.then((elements) => { 149 | return futureElement.then((element) => { 150 | if (element && elements) { 151 | elements.push(...element); 152 | } 153 | return elements; 154 | }); 155 | }); 156 | }, Promise.resolve([])); 157 | if (test) { 158 | test.forEach((x) => testList.appendChild(x)); 159 | } 160 | }); 161 | 162 | const button = document.createElement('button'); 163 | 164 | button.className = 'reset'; 165 | button.textContent = 'Reset all'; 166 | button.setAttribute('type', 'button'); 167 | button.addEventListener('click', () => { 168 | reset(); 169 | }); 170 | 171 | const close = document.createElement('span'); 172 | close.className = 'close'; 173 | close.textContent = 'X'; 174 | close.addEventListener('click', () => { 175 | hide(); 176 | }); 177 | header.appendChild(close); 178 | 179 | skift.appendChild(header); 180 | skift.appendChild(style); 181 | skift.appendChild(testList); 182 | skift.appendChild(button); 183 | document.body.appendChild(skift); 184 | isInitialized = true; 185 | } 186 | 187 | function show() { 188 | if (isInitialized) { 189 | skift.className = 'skift'; 190 | } else { 191 | showSplitTestUi(); 192 | } 193 | } 194 | 195 | function hide() { 196 | skift.className = 'skift hideme'; 197 | } 198 | 199 | return { 200 | hide, 201 | show, 202 | }; 203 | }; 204 | -------------------------------------------------------------------------------- /src/splitTest.ts: -------------------------------------------------------------------------------- 1 | import { alwaysPromise } from './alwaysPromise'; 2 | import { BehavioralSubject } from './behavioralSubject'; 3 | import { Condition } from './condition'; 4 | import config from './config'; 5 | import { setAbTestParameter } from './query'; 6 | import { 7 | TrackEventActionType, 8 | TrackEventType, 9 | TrackingData, 10 | TrackingDataExtender, 11 | trackingDataExtenderFactory, 12 | } from './tracking'; 13 | import { UserAgentInfo } from './userAgentInfo'; 14 | import userSession from './userSession'; 15 | 16 | export interface Variation { 17 | /** A descriptive unique name of this variation */ 18 | name: string; 19 | /** A relative weight defining how many users should see this variation. Default value is 1 */ 20 | weight?: number; 21 | /** 22 | * Function to be called when this variation has been chosen and should be setup. 23 | * It's always called after DOMContentLoaded 24 | */ 25 | setup?: (this: SplitTest, userAgentInfo: UserAgentInfo) => void; 26 | /** Whether a track event should automatically be published once this variation has been setup. Default is true. */ 27 | trackEventAutoPublish?: boolean; 28 | } 29 | 30 | export interface InternalVariation extends Variation { 31 | normalizedWeight: number; 32 | weight: number; 33 | } 34 | 35 | export type State = 'uninitialized' | 'initializing' | 'initialized' | 'canceled'; 36 | 37 | export class SplitTest { 38 | public state: State = 'uninitialized'; 39 | public changes = new BehavioralSubject(this); 40 | private finalStateListeners: Array<() => void> = []; 41 | private readonly _variations: InternalVariation[] = []; 42 | 43 | get variations(): InternalVariation[] { 44 | return this._variations; 45 | } 46 | 47 | constructor( 48 | public name: string, 49 | private userAgentInfo: UserAgentInfo, 50 | private trackingDataExtender: TrackingDataExtender, 51 | ) { 52 | this.extendTrackingData( 53 | trackingDataExtenderFactory({ 54 | experimentName: name, 55 | }), 56 | ); 57 | } 58 | 59 | /** 60 | * Determines whether this test is able to run or not. 61 | */ 62 | public async shouldRun(userAgentInfo: UserAgentInfo): Promise { 63 | const conditionPromises = [ 64 | config.globalCondition(userAgentInfo), 65 | this.condition(userAgentInfo), 66 | ].map(alwaysPromise); 67 | 68 | return (await Promise.all(conditionPromises)).every((a) => a); 69 | } 70 | 71 | public setCondition(condition: Condition): SplitTest { 72 | this.condition = condition; 73 | return this; 74 | } 75 | 76 | public addVariation(variation: Variation): SplitTest { 77 | if ( 78 | typeof variation.name !== 'string' || 79 | variation.name === '' || 80 | this.getVariation(variation.name) 81 | ) { 82 | throw new Error( 83 | `Split test "${this 84 | .name}": Variation must have a unique name. Was "${variation.name}"`, 85 | ); 86 | } 87 | this._variations.push({ 88 | ...variation, 89 | normalizedWeight: 0, 90 | weight: typeof variation.weight === 'number' ? variation.weight : 1, 91 | }); 92 | this.normalizeVariationWeights(); 93 | this.changes.next(this); 94 | return this; 95 | } 96 | 97 | public async setup(): Promise { 98 | if (this._variations.length === 0) { 99 | throw new Error("Skift: can't setup a test without variations"); 100 | } 101 | 102 | if (this.state === 'initialized') { 103 | // Already set up? 104 | return true; 105 | } 106 | 107 | this.transitionState('initializing'); 108 | 109 | // Step 1: Run condition function, if any 110 | const passesConditions = await this.shouldRun(this.userAgentInfo); 111 | if (!passesConditions) { 112 | this.transitionState('canceled'); 113 | return false; 114 | } 115 | 116 | // Step 2: Select variation 117 | let variation = this.getVariation( 118 | userSession.getTestVariation(this.name), 119 | ); 120 | if (!variation) { 121 | variation = this.selectRandomVariation(); 122 | userSession.setTestVariation(this.name, variation.name); 123 | } 124 | this.extendTrackingData( 125 | trackingDataExtenderFactory({ 126 | variationName: variation.name, 127 | }), 128 | ); 129 | 130 | // Step 3: Setup variation 131 | if (typeof variation.setup === 'function') { 132 | variation.setup.call(this, this.userAgentInfo); 133 | } 134 | 135 | // Step 4: Publish track event 136 | if (variation.trackEventAutoPublish !== false) { 137 | this.trackViewed(); 138 | } 139 | this.transitionState('initialized'); 140 | this.changes.next(this); 141 | return true; 142 | } 143 | 144 | public async isInitialized(): Promise { 145 | const { state } = this; 146 | 147 | if (state === 'initializing') { 148 | return await new Promise((resolve, reject) => { 149 | this.subscribeStateListener(() => { 150 | resolve(this.state === 'initialized'); 151 | }); 152 | }); 153 | } 154 | 155 | return this.state === 'initialized'; 156 | } 157 | 158 | public getVariation(name: string): InternalVariation { 159 | return this._variations.filter((v) => v.name === name)[0]; 160 | } 161 | 162 | public getVariationUrl(variationName: string | null): string { 163 | const param = `${this.name}=${variationName}`; 164 | 165 | try { 166 | return ( 167 | location.protocol + 168 | '//' + 169 | location.host + 170 | location.pathname + 171 | setAbTestParameter(location.search, btoa(param)) + 172 | location.hash 173 | ); 174 | } catch (e) { 175 | return location.href; 176 | } 177 | } 178 | 179 | /** 180 | * The tracking data extenders are called just before any event is published to the event handler. 181 | */ 182 | public extendTrackingData(trackingDataExtender: TrackingDataExtender): SplitTest { 183 | const currentExtender = this.trackingDataExtender; 184 | this.trackingDataExtender = ( 185 | trackingData: TrackingData, 186 | eventName: string, 187 | ) => { 188 | return trackingDataExtender( 189 | currentExtender(trackingData, eventName), 190 | eventName, 191 | ); 192 | }; 193 | return this; 194 | } 195 | 196 | /** 197 | * Emits an "Experiment Viewed" tracking event 198 | */ 199 | public trackViewed(): void { 200 | this.trackEvent('ExperimentViewed'); 201 | } 202 | 203 | /** 204 | * Emits an "Experiment Action Performed" tracking event 205 | * @param action Specifies the action type that has been performed 206 | * @param target Specifies a target the action has affected or originated from 207 | */ 208 | public trackActionPerformed(action: TrackEventActionType, target?: string): void { 209 | this.trackEvent('ExperimentActionPerformed', { 210 | action, 211 | actionTarget: target || '', 212 | }); 213 | } 214 | 215 | /** 216 | * Attaches a trackActionPerformed call as a handler to a link. 217 | * @param element The DOM element to be bound with track method. 218 | * @param name A human readable name of the link. If left out, the innerText of the element is used 219 | */ 220 | public trackLink(element: Element, name?: string): void { 221 | const event: TrackEventType = 'ExperimentActionPerformed'; 222 | const trackingData = this.trackingDataExtender({ 223 | action: 'Click', 224 | actionTarget: name || element.textContent, 225 | }, event); 226 | config.tracking.trackLink(element, event, trackingData); 227 | } 228 | 229 | private condition: Condition = () => true; 230 | 231 | private normalizeVariationWeights(): void { 232 | const weightsSum = this._variations.reduce( 233 | (sum, variation) => sum + variation.weight, 234 | 0, 235 | ); 236 | this._variations.forEach((variation) => { 237 | variation.normalizedWeight = variation.weight / weightsSum; 238 | }); 239 | } 240 | 241 | private transitionState(state: State) { 242 | this.state = state; 243 | if (state !== 'initializing') { 244 | this.finalStateListeners.forEach((l) => l()); 245 | this.finalStateListeners = []; 246 | } 247 | } 248 | 249 | private subscribeStateListener(listener: () => void) { 250 | this.finalStateListeners.push(listener); 251 | } 252 | 253 | private selectRandomVariation(): InternalVariation { 254 | let i = 0; 255 | // Disable the rule for now and refactor this, when covered by a test. 256 | // tslint:disable:max-line-length no-conditional-assignment no-empty 257 | for ( 258 | let runningTotal = 0, testSegment = Math.random(); 259 | i < this._variations.length && (runningTotal += this._variations[i].normalizedWeight) < testSegment; 260 | i++ 261 | ) { } 262 | // tslint:enable:max-line-length no-conditional-assignment no-empty 263 | return this._variations[i]; 264 | } 265 | 266 | private trackEvent( 267 | event: TrackEventType, 268 | trackingData?: TrackingData, 269 | ): void { 270 | const allTrackingData = this.trackingDataExtender( 271 | trackingData || {}, 272 | event, 273 | ); 274 | config.tracking.track(event, allTrackingData); 275 | } 276 | } 277 | --------------------------------------------------------------------------------