diff --git a/packages/suite/src/components/suite/Experiment/ExperimentWrapper.tsx b/packages/suite/src/components/suite/Experiment/ExperimentWrapper.tsx new file mode 100644 index 000000000000..54d2933b9a54 --- /dev/null +++ b/packages/suite/src/components/suite/Experiment/ExperimentWrapper.tsx @@ -0,0 +1,37 @@ +import { ReactElement } from 'react'; + +import { useExperiment } from 'src/hooks/experiment/useExperiment'; + +interface ExperimentWrapperProps { + id: string; + components: Array<{ + variant: string; + element: ReactElement; + }>; +} + +/** + * @param components last item in components is default + */ +export const ExperimentWrapper = ({ + id, + components, +}: ExperimentWrapperProps): ReactElement | null => { + const { experiment, activeExperimentVariant } = useExperiment(id); + const areComponentEmpty = !components.length; + + if (areComponentEmpty) return null; + + const defaultComponent = components[components.length - 1]; + const experimentOrVariantNotFound = !experiment || !activeExperimentVariant; + const experimentAndComponentsMismatch = experiment?.groups.length !== components.length; + + if (experimentOrVariantNotFound || experimentAndComponentsMismatch) + return defaultComponent.element; + + const activeComponent = components.find( + component => component.variant === activeExperimentVariant.variant, + ); + + return activeComponent?.element ?? defaultComponent.element; +}; diff --git a/packages/suite/src/hooks/experiment/useExperiment.ts b/packages/suite/src/hooks/experiment/useExperiment.ts new file mode 100644 index 000000000000..55ba951153d4 --- /dev/null +++ b/packages/suite/src/hooks/experiment/useExperiment.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; + +import { selectAnalyticsInstanceId } from '@suite-common/analytics'; +import { selectExperimentById } from '@suite-common/message-system'; + +import { useSelector } from 'src/hooks/suite'; +import { selectActiveExperimentGroup } from 'src/utils/suite/experiment'; + +export const useExperiment = (id: string) => { + const state = useSelector(state => state); + const instanceId = selectAnalyticsInstanceId(state); + const experiment = useSelector(selectExperimentById(id)); + const activeExperimentVariant = useMemo( + () => selectActiveExperimentGroup({ instanceId, experiment }), + [instanceId, experiment], + ); + + return { + experiment, + activeExperimentVariant, + }; +}; diff --git a/packages/suite/src/utils/suite/__fixtures__/experiment.ts b/packages/suite/src/utils/suite/__fixtures__/experiment.ts new file mode 100644 index 000000000000..0d2e6d084382 --- /dev/null +++ b/packages/suite/src/utils/suite/__fixtures__/experiment.ts @@ -0,0 +1,19 @@ +import { getWeakRandomId } from '@trezor/utils'; + +// getWeakRandomId is also used for generating instanceId +export const getArrayOfInstanceIds = (count: number) => + Array.from({ length: count }, () => getWeakRandomId(10)); + +export const experimentTest = { + id: 'experiment-test', + groups: [ + { + variant: 'A', + percentage: 20, + }, + { + variant: 'B', + percentage: 80, + }, + ], +}; diff --git a/packages/suite/src/utils/suite/__tests__/experiment.test.ts b/packages/suite/src/utils/suite/__tests__/experiment.test.ts new file mode 100644 index 000000000000..036094880bef --- /dev/null +++ b/packages/suite/src/utils/suite/__tests__/experiment.test.ts @@ -0,0 +1,70 @@ +import { experimentTest, getArrayOfInstanceIds } from 'src/utils/suite/__fixtures__/experiment'; +import { + getExperimentGroupByInclusion, + getInclusionFromInstanceId, + selectActiveExperimentGroup, +} from 'src/utils/suite/experiment'; + +describe('testing experiment utils', () => { + it('test getInclusionFromInstanceId whether returns percentage between 0 and 99', () => { + const arrayOfIds = getArrayOfInstanceIds(100); + const isExistNumberOutOfRange = arrayOfIds.some(id => { + const percentage = getInclusionFromInstanceId(id); + + return percentage < 0 || percentage > 99; + }); + + expect(isExistNumberOutOfRange).toEqual(false); + }); + + it('test getExperimentGroupByInclusion whether instanceId is not in range of variants', () => { + const arrayOfIds = getArrayOfInstanceIds(100); + const isExistInstanceIdNotInVariantRange = arrayOfIds.some(id => { + const inclusion = getInclusionFromInstanceId(id); + const group = getExperimentGroupByInclusion({ + groups: experimentTest.groups, + inclusion, + }); + + return group === undefined; + }); + + expect(isExistInstanceIdNotInVariantRange).toEqual(false); + }); + + it('test selectActiveExperimentGroup share of variant inclusion', () => { + const deviation = 0.05; + const sampleSize = 1000; + let groupACount = 0; + let groupBCount = 0; + + const arrayOfIds = getArrayOfInstanceIds(sampleSize); + + arrayOfIds.forEach(id => { + const selectedGroup = selectActiveExperimentGroup({ + experiment: experimentTest, + instanceId: id, + }); + + if (selectedGroup?.variant === 'A') { + groupACount += 1; + } + + if (selectedGroup?.variant === 'B') { + groupBCount += 1; + } + }); + + const shareA = groupACount / sampleSize; + const shareB = groupBCount / sampleSize; + + expect(shareA).toBeGreaterThanOrEqual( + experimentTest.groups[0].percentage / 100 - deviation, + ); + expect(shareA).toBeLessThanOrEqual(experimentTest.groups[0].percentage / 100 + deviation); + expect(shareB).toBeGreaterThanOrEqual( + experimentTest.groups[1].percentage / 100 - deviation, + ); + expect(shareB).toBeLessThanOrEqual(experimentTest.groups[1].percentage / 100 + deviation); + }); +}); diff --git a/packages/suite/src/utils/suite/experiment.ts b/packages/suite/src/utils/suite/experiment.ts new file mode 100644 index 000000000000..4ae5310fd18b --- /dev/null +++ b/packages/suite/src/utils/suite/experiment.ts @@ -0,0 +1,64 @@ +import { createHash } from 'crypto'; + +import { ExperimentsItem } from '@suite-common/suite-types'; + +type ExperimentCategoriesProps = { + experiment: ExperimentsItem | undefined; + instanceId: string | undefined; +}; + +type ExperimentsGroupsType = ExperimentsItem['groups']; +type ExperimentsGroupType = ExperimentsGroupsType[number]; + +type ExperimentGetGroupByInclusion = { + groups: ExperimentsGroupsType; + inclusion: number; +}; + +/** + * @returns number between 0 and 99 generated from instanceId + */ +export const getInclusionFromInstanceId = (instanceId: string) => { + const hash = createHash('sha256').update(instanceId).digest('hex').slice(0, 8); + + return parseInt(hash, 16) % 100; +}; + +export const getExperimentGroupByInclusion = ({ + groups, + inclusion, +}: ExperimentGetGroupByInclusion): ExperimentsGroupType | undefined => { + let currentPercentage = 0; + + const extendedExperiment = groups.map(group => { + const result = { + group, + range: [currentPercentage, currentPercentage + group.percentage - 1], + }; + + currentPercentage += group.percentage; + + return result; + }); + + return extendedExperiment.find( + group => group.range[0] <= inclusion && group.range[1] >= inclusion, + )?.group; +}; + +export const selectActiveExperimentGroup = ({ + experiment, + instanceId, +}: ExperimentCategoriesProps): ExperimentsGroupType | undefined => { + if (!instanceId || !experiment) return undefined; + + const inclusionFromInstanceId = getInclusionFromInstanceId(instanceId); + const { groups } = experiment; + + const experimentRange = getExperimentGroupByInclusion({ + groups, + inclusion: inclusionFromInstanceId, + }); + + return experimentRange; +};