Skip to content

Commit

Permalink
feat(splits): add splits
Browse files Browse the repository at this point in the history
  • Loading branch information
andrepolischuk committed Aug 13, 2024
1 parent 230cc52 commit 247607a
Show file tree
Hide file tree
Showing 9 changed files with 343 additions and 1 deletion.
4 changes: 4 additions & 0 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@
{
"path": "packages/session-storage/dist/index.js",
"limit": "290 B"
},
{
"path": "packages/splits/dist/index.js",
"limit": "1.6 KB"
}
]
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Common utils used by Rambler team
- [@rambler-tech/local-storage](packages/local-storage)
- [@rambler-tech/session-storage](packages/session-storage)
- [@rambler-tech/lhci-report](packages/lhci-report)
- [@rambler-tech/splits](packages/splits)

## Contributing

Expand Down
5 changes: 4 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ module.exports = {
moduleDirectories: ['packages', 'node_modules'],
collectCoverage: true,
coverageReporters: ['text'],
setupFilesAfterEnv: ['./jest.setup.js']
setupFilesAfterEnv: ['./jest.setup.js'],
testEnvironmentOptions: {
url: 'https://example.com/'
}
}
15 changes: 15 additions & 0 deletions packages/splits/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Local Storage

Enhanced local storage API

## Install

```
npm install -D @rambler-tech/local-storage
```

or

```
yarn add -D @rambler-tech/local-storage
```
115 changes: 115 additions & 0 deletions packages/splits/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {getItem, setItem} from '@rambler-tech/cookie-storage'
import {Splits, SplitsVariations} from '.'

let splits: Splits
let initialVariations: SplitsVariations

setItem('foo', 'bar')

test('initial split', () => {
const tests = [
{
name: 'login',
variations: [
{
name: 'compact',
probability: [0, 0.5]
}
]
},
{
name: 'phone',
cookie: 'login',
variations: [
{
name: 'without',
probability: [0.5, 1]
}
]
}
]

const options = {
prefix: 'x_'
}

splits = new Splits(tests, options)
initialVariations = splits.getVariations()

expect(Object.keys(initialVariations)).toEqual(tests.map((ts) => ts.name))
expect(initialVariations.login).not.toBe(initialVariations.phone)
expect(getItem('foo')).toBe('bar')
expect(getItem('x_login')).toBeTruthy()
})

test('get variations', () => {
const variations = splits.getVariations()

expect(variations).toEqual(initialVariations)
expect(variations.login).not.toBe(variations.phone)
expect(getItem('foo')).toBe('bar')
expect(getItem('x_login')).toBeTruthy()
})

test('drop irrelevant cookies', () => {
const tests = [
{
name: 'phone',
variations: [
{
name: 'without',
probability: [0.5, 1]
}
]
}
]

const options = {
prefix: 'x_'
}

const nextSplits = new Splits(tests, options)

nextSplits.getVariations()

expect(getItem('foo')).toBe('bar')
expect(getItem('x_phone')).toBeTruthy()
expect(getItem('x_login')).toBeFalsy()
})

test('wrong settings', () => {
expect(() => new Splits({} as any)).toThrow(
'expected `settings.tests` is not empty array'
)
expect(
() =>
new Splits([
{
name: 'phone number'
}
] as any)
).toThrow('expected `phone number` contains `A-Za-z0-9_-` symbols only')
expect(
() =>
new Splits([
{
name: 'phone',
variations: null
}
] as any)
).toThrow('expected `variations` of `phone` is not empty array')
expect(
() =>
new Splits([
{
name: 'phone',
variations: [
{
name: 'short',
probability: null
}
]
}
] as any)
).toThrow('expected `probability` of `phone.short` is array of numbers')
})
175 changes: 175 additions & 0 deletions packages/splits/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/* eslint-disable import/no-unused-modules */
import {
getAll,
getItem,
setItem,
removeItem,
type SetCookieItemOptions
} from '@rambler-tech/cookie-storage'

const DEFAULT_OPTIONS: SplitsOptions = {
path: '/',
expires: 182,
domain: `.${window.location.hostname}`,
samesite: 'none',
secure: true
}

/** Split test declaration */
export interface SplitTest {
/** Test name */
name: string
/** Custom cookie name */
cookie?: string
/** Test variations */
variations: SplitTestVariation[]
}

/** Split test variation declaration */
export interface SplitTestVariation {
/** Variation name */
name: string
/** Variation probability */
probability: number[]
}

/** Split options */
export interface SplitsOptions extends Omit<SetCookieItemOptions, 'raw'> {
/** Cookie prefix */
prefix?: string
}

/** Split calculation result */
export interface SplitsVariations {
[key: string]: string
}

/**
* Splits calculator
*
* ```ts
* const tests = [
* {
* name: 'loginType',
* variations: [
* {
* name: 'compact',
* probability: [0, 0.5]
* },
* {
* name: 'full',
* probability: [0.5, 1]
* }
* ]
* }
* ]
*
* const splits = new Splits(tests, {prefix: 'x_'})
* const variations = splits.getVariations()
*
* // {loginType: 'compact'}
* ```
*/
export class Splits {
private tests: SplitTest[]
private options: SplitsOptions

public constructor(tests: SplitTest[], options: SplitsOptions = {}) {
if (!Array.isArray(tests) || !tests.length) {
throw new Error('expected `settings.tests` is not empty array')
}

tests.forEach(({name, variations}) => {
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
throw new Error(
`expected \`${name}\` contains \`A-Za-z0-9_-\` symbols only`
)
}

if (!Array.isArray(variations) || !variations.length) {
throw new Error(
`expected \`variations\` of \`${name}\` is not empty array`
)
}

variations.forEach(({name: varName, probability}) => {
if (
!Array.isArray(probability) ||
typeof probability[0] !== 'number' ||
typeof probability[1] !== 'number'
) {
throw new Error(
`expected \`probability\` of \`${name}.${varName}\` is array of numbers`
)
}
})
})

this.tests = tests
this.options = {...DEFAULT_OPTIONS, ...options}
}

/** Get calculated variations */
public getVariations(): SplitsVariations {
const variations = this.tests.reduce<SplitsVariations>(
(accumulator, {name, cookie, variations}) => {
accumulator[name] = this.chooseVariation(
variations,
this.getProbability(cookie || name, this.options)
)

return accumulator
},
{}
)

this.dropIrrelevantProbabilities()

return variations
}

private dropIrrelevantProbabilities(): void {
const items = getAll()

const {
tests,
options: {prefix = '', ...options}
} = this

if (items) {
const removal = Object.keys(items).filter(
(cookieName) =>
cookieName.indexOf(prefix) === 0 &&
!tests.filter(
({name, cookie}) => cookieName === `${prefix}${cookie || name}`
).length
)

removal.forEach((cookieName) => removeItem(cookieName, options))
}
}

private getProbability(name: string, {prefix = '', ...options}): number {
const cookieName = `${prefix}${name}`
let probability = getItem<number>(cookieName)

if (!probability || typeof probability !== 'number') {
probability = Math.random()
}

setItem<number>(cookieName, probability, options)

return probability
}

private chooseVariation(
variations: SplitTestVariation[],
probability: number
): string {
return variations.reduce<string>(
(accumulator, {name, probability: [start, end]}) =>
probability >= start && probability < end ? name : accumulator,
'default'
)
}
}
15 changes: 15 additions & 0 deletions packages/splits/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "@rambler-tech/splits",
"version": "0.0.0",
"main": "dist",
"module": "dist",
"types": "dist/index.d.ts",
"license": "MIT",
"sideEffects": false,
"publishConfig": {
"access": "public"
},
"dependencies": {
"@rambler-tech/cookie-storage": "*"
}
}
9 changes: 9 additions & 0 deletions packages/splits/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"baseUrl": ".",
"outDir": "dist"
},
"include": [".", "../../types"]
}
5 changes: 5 additions & 0 deletions packages/splits/typedoc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "@rambler-tech/typedoc-config",
"readme": "../../README.md",
"entryPoints": ["./index.ts"]
}

0 comments on commit 247607a

Please sign in to comment.