fix(ui): some ui reviews and fixes for feature flags (#1355)
* fix(ui): some ui reviews and fixes for feature flags * feat(ui): added some tests * feat(ui): added some tests
This commit is contained in:
parent
c8f391c771
commit
15e2744acb
16 changed files with 1033 additions and 453 deletions
6
.github/workflows/ui-tests.js.yml
vendored
6
.github/workflows/ui-tests.js.yml
vendored
|
|
@ -104,6 +104,12 @@ jobs:
|
|||
run: |
|
||||
cd frontend
|
||||
yarn test
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: ui
|
||||
name: ui
|
||||
- name: Run Frontend
|
||||
run: |
|
||||
cd frontend
|
||||
|
|
|
|||
|
|
@ -10,10 +10,15 @@ function FFlagItem({ flag }: { flag: FeatureFlag }) {
|
|||
const { featureFlagsStore, userStore } = useStore();
|
||||
|
||||
const toggleActivity = () => {
|
||||
flag.setIsEnabled(!flag.isActive);
|
||||
const newValue = !flag.isActive
|
||||
flag.setIsEnabled(newValue);
|
||||
featureFlagsStore.updateFlag(flag, true).then(() => {
|
||||
toast.success('Feature flag updated.');
|
||||
})
|
||||
.catch(() => {
|
||||
flag.setIsEnabled(!newValue);
|
||||
toast.error('Failed to update flag.')
|
||||
})
|
||||
}
|
||||
|
||||
const flagIcon = flag.isSingleOption ? 'fflag-single' : 'fflag-multi' as const
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import FilterList from 'Shared/Filters/FilterList';
|
|||
import { nonFlagFilters } from 'Types/filter/newFilter';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Conditions } from "App/mstore/types/FeatureFlag";
|
||||
import FilterSelection from 'Shared/Filters/FilterSelection';
|
||||
|
||||
interface Props {
|
||||
set: number;
|
||||
|
|
@ -15,8 +16,8 @@ interface Props {
|
|||
|
||||
function RolloutCondition({ set, conditions, removeCondition, index }: Props) {
|
||||
const [forceRender, forceRerender] = React.useState(false);
|
||||
const onAddFilter = () => {
|
||||
conditions.filter.addFilter({});
|
||||
const onAddFilter = (filter = {}) => {
|
||||
conditions.filter.addFilter(filter);
|
||||
forceRerender(!forceRender);
|
||||
};
|
||||
const onUpdateFilter = (filterIndex: number, filter: any) => {
|
||||
|
|
@ -48,7 +49,7 @@ function RolloutCondition({ set, conditions, removeCondition, index }: Props) {
|
|||
<div className={'p-2 rounded bg-gray-lightest'}>Set {set}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 cursor-pointer rounded ml-auto',
|
||||
'p-2 px-4 cursor-pointer rounded ml-auto',
|
||||
'hover:bg-teal-light'
|
||||
)}
|
||||
onClick={() => removeCondition(index)}
|
||||
|
|
@ -63,14 +64,17 @@ function RolloutCondition({ set, conditions, removeCondition, index }: Props) {
|
|||
onUpdateFilter={onUpdateFilter}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onChangeEventsOrder={onChangeEventsOrder}
|
||||
supportsEmpty
|
||||
hideEventsOrder
|
||||
excludeFilterKeys={nonFlagFilters}
|
||||
/>
|
||||
</div>
|
||||
<Button variant={'text-primary'} onClick={() => onAddFilter()}>
|
||||
+ Add Condition
|
||||
</Button>
|
||||
<FilterSelection
|
||||
filter={undefined}
|
||||
onFilterClick={onAddFilter}
|
||||
excludeFilterKeys={nonFlagFilters}
|
||||
>
|
||||
<Button variant="text-primary" icon="plus">Add Condition</Button>
|
||||
</FilterSelection>
|
||||
</div>
|
||||
<div className={'px-4 py-2 flex items-center gap-2'}>
|
||||
<span>Rollout to</span>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ function Header({ current, onCancel, onSave, isNew }: any) {
|
|||
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<Button variant="text-primary" onClick={onCancel}>
|
||||
{isNew ? "Cancel" : "Back"}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onSave}>
|
||||
Save
|
||||
|
|
|
|||
|
|
@ -5,6 +5,35 @@ import { observer } from 'mobx-react-lite';
|
|||
import { useStore } from 'App/mstore';
|
||||
import cn from 'classnames';
|
||||
|
||||
const alphabet = [
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
'D',
|
||||
'E',
|
||||
'F',
|
||||
'G',
|
||||
'H',
|
||||
'I',
|
||||
'J',
|
||||
'K',
|
||||
'L',
|
||||
'M',
|
||||
'N',
|
||||
'O',
|
||||
'P',
|
||||
'Q',
|
||||
'R',
|
||||
'S',
|
||||
'T',
|
||||
'U',
|
||||
'V',
|
||||
'W',
|
||||
'X',
|
||||
'Y',
|
||||
'Z',
|
||||
];
|
||||
|
||||
function Multivariant() {
|
||||
const { featureFlagsStore } = useStore();
|
||||
|
||||
|
|
@ -14,17 +43,23 @@ function Multivariant() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className={'text-sm text-disabled-text mt-1 flex items-center gap-1'}>
|
||||
Users who meet release conditions will be server variant's
|
||||
<code className={'p-1 text-red rounded bg-gray-lightest'}>key</code> based on specific
|
||||
distribution.
|
||||
</div>
|
||||
<div className={'flex items-center gap-2 font-semibold mt-4'}>
|
||||
<div style={{ flex: 1 }}>Variant</div>
|
||||
<div style={{ flex: 3 }}>Key</div>
|
||||
<div style={{ flex: 3 }}>Description</div>
|
||||
<div style={{ flex: 3 }}>
|
||||
<div style={{ flex: 4 }}>Key</div>
|
||||
<div style={{ flex: 4 }}>Description</div>
|
||||
<div style={{ flex: 4 }}>
|
||||
<Payload />
|
||||
</div>
|
||||
<div style={{ flex: 3 }} className={'flex items-center gap-2'}>
|
||||
<Rollout />{' '}
|
||||
<div style={{ flex: 4 }} className={'flex items-center'}>
|
||||
<Rollout />
|
||||
<Button
|
||||
variant={'text-primary'}
|
||||
className={'font-normal ml-auto'}
|
||||
onClick={featureFlagsStore.currentFflag!.redistributeVariants}
|
||||
>
|
||||
Distribute Equally
|
||||
|
|
@ -33,79 +68,82 @@ function Multivariant() {
|
|||
</div>
|
||||
<div>
|
||||
{featureFlagsStore.currentFflag!.variants.map((variant, ind) => {
|
||||
console.log(variant, featureFlagsStore.currentFflag)
|
||||
return (
|
||||
<div className={'flex items-center gap-2 my-2 '} key={variant.index}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className={'p-2 text-center bg-gray-lightest rounded-full w-10 h-10'}>
|
||||
{ind + 1}
|
||||
<div className={'flex items-center gap-2 my-2 '} key={variant.index}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className={'p-2 text-center bg-gray-lightest rounded-full w-10 h-10'}>
|
||||
{alphabet[ind] || ind + 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 4 }}>
|
||||
<Input
|
||||
placeholder={'buy-btn-variant-1'}
|
||||
value={variant.value}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
variant.setKey(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 4 }}>
|
||||
<Input
|
||||
placeholder={'Very red button'}
|
||||
value={variant.description}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
variant.setDescription(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 4 }}>
|
||||
<Input
|
||||
placeholder={"Example: very important button, {'buttonColor': 'red'}"}
|
||||
value={variant.payload}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
variant.setPayload(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 4 }} className={'flex items-center gap-2'}>
|
||||
<Input
|
||||
className={
|
||||
featureFlagsStore.currentFflag!.isRedDistribution
|
||||
? '!border-red !text-red !w-full'
|
||||
: '!w-full'
|
||||
}
|
||||
type={'tel'}
|
||||
placeholder={avg}
|
||||
value={variant.rolloutPercentage}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
variant.setRollout(parseInt(e.target.value.replace(/\D/g, ''), 10))
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 cursor-pointer rounded ml-auto',
|
||||
featureFlagsStore.currentFflag!.variants.length === 1
|
||||
? 'cursor-not-allowed'
|
||||
: 'hover:bg-teal-light'
|
||||
)}
|
||||
onClick={() =>
|
||||
featureFlagsStore.currentFflag!.variants.length === 1
|
||||
? null
|
||||
: featureFlagsStore.currentFflag!.removeVariant(variant.index)
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={'trash'}
|
||||
color={featureFlagsStore.currentFflag!.variants.length === 1 ? '' : 'main'}
|
||||
<div style={{ flex: 4 }}>
|
||||
<Input
|
||||
placeholder={'buy-btn-variant-1'}
|
||||
value={variant.value}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
variant.setKey(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 4 }}>
|
||||
<Input
|
||||
placeholder={'Very red button'}
|
||||
value={variant.description}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
variant.setDescription(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 4 }}>
|
||||
<Input
|
||||
placeholder={"Example: very important button, {'buttonColor': 'red'}"}
|
||||
value={variant.payload}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
variant.setPayload(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 4 }} className={'flex items-center gap-2'}>
|
||||
<Input
|
||||
className={'!flex-1'}
|
||||
type={'tel'}
|
||||
wrapperClassName={'flex-1'}
|
||||
placeholder={avg}
|
||||
value={variant.rolloutPercentage}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
variant.setRollout(parseInt(e.target.value.replace(/\D/g, ''), 10))
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 cursor-pointer rounded',
|
||||
featureFlagsStore.currentFflag!.variants.length === 1
|
||||
? 'cursor-not-allowed'
|
||||
: 'hover:bg-teal-light'
|
||||
)}
|
||||
onClick={() =>
|
||||
featureFlagsStore.currentFflag!.variants.length === 1
|
||||
? null
|
||||
: featureFlagsStore.currentFflag!.removeVariant(variant.index)
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={'trash'}
|
||||
color={featureFlagsStore.currentFflag!.variants.length === 1 ? '' : 'main'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={'mt-2 flex justify-between w-full pr-4'}>
|
||||
<Button variant={'text-primary'} onClick={featureFlagsStore.currentFflag!.addVariant}>
|
||||
+ Add Variant
|
||||
</Button>
|
||||
{featureFlagsStore.currentFflag!.isRedDistribution ? (
|
||||
<div className={'text-red'}>Total distribution is less than 100%</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button variant={'text-primary'} onClick={featureFlagsStore.currentFflag!.addVariant}>
|
||||
+ Add Variant
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
|
|||
in your code.
|
||||
</div>
|
||||
|
||||
<div className={'mt-4'}>
|
||||
<div className={'mt-6'}>
|
||||
<Description
|
||||
current={current}
|
||||
isDescrEditing={featureFlagsStore.isDescrEditing}
|
||||
|
|
@ -119,7 +119,7 @@ function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className={'mt-4'}>
|
||||
<div className={'mt-6'}>
|
||||
<label className={'font-semibold'}>Feature Type</label>
|
||||
<div style={{ width: 340 }}>
|
||||
<SegmentSelection
|
||||
|
|
@ -144,7 +144,7 @@ function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
|
|||
<code className={'p-1 text-red rounded bg-gray-lightest'}>true</code> if they match
|
||||
one or more rollout conditions.
|
||||
</div>
|
||||
<div>
|
||||
<div className={"mt-6"}>
|
||||
<Payload />
|
||||
<Input placeholder={"Example: very important button, {'buttonColor': 'red'}"} className={'mt-2'} />
|
||||
</div>
|
||||
|
|
@ -154,7 +154,7 @@ function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className={'mt-4'}>
|
||||
<div className={'mt-6'}>
|
||||
<label className={'font-semibold'}>Persist flag across authentication</label>
|
||||
<Toggler
|
||||
checked={current.isPersist}
|
||||
|
|
@ -169,7 +169,7 @@ function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'mt-4'}>
|
||||
<div className={'mt-6'}>
|
||||
<label className={'font-semibold'}>Enable this feature flag (Status)?</label>
|
||||
<Toggler
|
||||
checked={current.isActive}
|
||||
|
|
@ -181,11 +181,11 @@ function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className={'mt-4 p-4 rounded bg-gray-lightest'}>
|
||||
<div className={'mt-6 p-4 rounded bg-gray-lightest'}>
|
||||
<label className={'font-semibold'}>Rollout Conditions</label>
|
||||
{current.conditions.length === 0 ? null
|
||||
: (
|
||||
<div className={'text-sm text-disabled-text'}>
|
||||
<div className={'text-sm text-disabled-text mb-2'}>
|
||||
Indicate the users for whom you intend to make this flag available. Keep in mind that
|
||||
each set of conditions will be deployed separately from one another.
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,9 +18,11 @@ export default class FeatureFlagsStore {
|
|||
sort = { order: 'DESC', query: '' };
|
||||
page: number = 1;
|
||||
readonly pageSize: number = 10;
|
||||
client: typeof fflagsService
|
||||
|
||||
constructor() {
|
||||
constructor(customClient?: typeof fflagsService) {
|
||||
makeAutoObservable(this);
|
||||
this.client = customClient ?? fflagsService
|
||||
}
|
||||
|
||||
setFlagsSearch = (search: string) => {
|
||||
|
|
@ -83,7 +85,7 @@ export default class FeatureFlagsStore {
|
|||
isActive: this.activity === '0' ? undefined : this.activity === '1',
|
||||
// userId: 3,
|
||||
}
|
||||
const { list } = await fflagsService.fetchFlags(filters);
|
||||
const { list } = await this.client.fetchFlags(filters);
|
||||
const flags = list.map((record) => new FeatureFlag(record));
|
||||
this.setList(flags);
|
||||
} catch (e) {
|
||||
|
|
@ -98,8 +100,11 @@ export default class FeatureFlagsStore {
|
|||
if (this.currentFflag.flagKey === '') {
|
||||
return 'Feature flag must have a key'
|
||||
}
|
||||
if (this.currentFflag?.variants.findIndex((v) => v.value === '') !== -1) {
|
||||
return 'Variants must include key'
|
||||
if (!this.currentFflag.isSingleOption && this.currentFflag?.variants.findIndex((v) => v.value === '') !== -1) {
|
||||
return 'All variants must include unique key'
|
||||
}
|
||||
if (this.currentFflag?.isRedDistribution) {
|
||||
return 'Variants rollout percentage must add up to 100%'
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -109,7 +114,7 @@ export default class FeatureFlagsStore {
|
|||
this.setLoading(true);
|
||||
try {
|
||||
// @ts-ignore
|
||||
const result = await fflagsService.createFlag(this.currentFflag.toJS());
|
||||
const result = await this.client.createFlag(this.currentFflag.toJS());
|
||||
this.addFlag(new FeatureFlag(result));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
|
@ -128,7 +133,7 @@ export default class FeatureFlagsStore {
|
|||
}
|
||||
try {
|
||||
// @ts-ignore
|
||||
const result = await fflagsService.updateFlag(usedFlag.toJS());
|
||||
const result = await this.client.updateFlag(usedFlag.toJS());
|
||||
if (!flag) this.setCurrentFlag(new FeatureFlag(result));
|
||||
} catch (e) {
|
||||
console.error('getting api error', e);
|
||||
|
|
@ -142,7 +147,7 @@ export default class FeatureFlagsStore {
|
|||
deleteFlag = async (id: FeatureFlag['featureFlagId']) => {
|
||||
this.setLoading(true);
|
||||
try {
|
||||
await fflagsService.deleteFlag(id);
|
||||
await this.client.deleteFlag(id);
|
||||
this.removeFromList(id);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
|
@ -154,7 +159,7 @@ export default class FeatureFlagsStore {
|
|||
fetchFlag = async (id: FeatureFlag['featureFlagId']) => {
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const result = await fflagsService.getFlag(id);
|
||||
const result = await this.client.getFlag(id);
|
||||
this.setCurrentFlag(new FeatureFlag(result));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ export class Variant {
|
|||
rolloutPercentage: number = 100;
|
||||
|
||||
constructor(index: number, data?: Record<string, any>) {
|
||||
makeAutoObservable(this)
|
||||
Object.assign(this, data)
|
||||
this.index = index;
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
|
||||
setIndex = (index: number) => {
|
||||
|
|
@ -145,7 +145,7 @@ export default class FeatureFlag {
|
|||
isPersist: this.isPersist,
|
||||
flagType: this.isSingleOption ? 'single' as const : 'multi' as const,
|
||||
featureFlagId: this.featureFlagId,
|
||||
variants: this.isSingleOption ? undefined : this.variants.map(v => ({ value: v.value, description: v.description, payload: v.payload, rolloutPercentage: v.rolloutPercentage })),
|
||||
variants: this.isSingleOption ? undefined : this.variants?.map(v => ({ value: v.value, description: v.description, payload: v.payload, rolloutPercentage: v.rolloutPercentage })),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -381,6 +381,7 @@ export default class Session {
|
|||
|
||||
addNotes(sessionNotes: Note[]) {
|
||||
sessionNotes.forEach((note) => {
|
||||
// @ts-ignore veri dirti
|
||||
note.time = note.timestamp
|
||||
})
|
||||
// @ts-ignore
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
rootDir: './',
|
||||
testEnvironment: 'node',
|
||||
testEnvironment: 'jsdom',
|
||||
moduleNameMapper: {
|
||||
'^Types/session/(.+)$': '<rootDir>/app/types/session/$1',
|
||||
'^App/(.+)$': '<rootDir>/app/$1',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"html-to-image": "^1.9.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
"jsbi": "^4.1.0",
|
||||
"jshint": "^2.11.1",
|
||||
"jspdf": "^2.5.1",
|
||||
|
|
|
|||
96
frontend/tests/featureFlag.type.test.js
Normal file
96
frontend/tests/featureFlag.type.test.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import FeatureFlag, { Conditions, Variant } from '../app/mstore/types/FeatureFlag';
|
||||
import { jest, test, expect, describe } from '@jest/globals';
|
||||
|
||||
|
||||
jest.mock('App/mstore/types/filter', () => {
|
||||
let filterData = { filters: [] }
|
||||
|
||||
class MockFilter {
|
||||
ID_KEY = "filterId"
|
||||
filterId = ''
|
||||
name = ''
|
||||
filters = []
|
||||
eventsOrder = 'then'
|
||||
eventsOrderSupport = ['then', 'or', 'and']
|
||||
startTimestamp = 0
|
||||
endTimestamp = 0
|
||||
fromJson(json) {
|
||||
this.name = json.name
|
||||
this.filters = json.filters.map((i) => i)
|
||||
this.eventsOrder = json.eventsOrder
|
||||
return this
|
||||
}
|
||||
}
|
||||
return MockFilter
|
||||
})
|
||||
|
||||
describe('Feature flag type test', () => {
|
||||
// Test cases for Conditions class
|
||||
test('Conditions class methods work correctly', () => {
|
||||
const conditions = new Conditions();
|
||||
|
||||
conditions.setRollout(50);
|
||||
expect(conditions.rolloutPercentage).toBe(50);
|
||||
|
||||
const jsObject = conditions.toJS();
|
||||
expect(jsObject.rolloutPercentage).toBe(50);
|
||||
});
|
||||
|
||||
// Test cases for Variant class
|
||||
test('Variant class methods work correctly', () => {
|
||||
const variant = new Variant(1);
|
||||
|
||||
variant.setIndex(2);
|
||||
expect(variant.index).toBe(2);
|
||||
|
||||
variant.setKey('key');
|
||||
expect(variant.value).toBe('key');
|
||||
|
||||
variant.setDescription('description');
|
||||
expect(variant.description).toBe('description');
|
||||
|
||||
variant.setPayload('payload');
|
||||
expect(variant.payload).toBe('payload');
|
||||
|
||||
variant.setRollout(90);
|
||||
expect(variant.rolloutPercentage).toBe(90);
|
||||
});
|
||||
|
||||
// Test cases for FeatureFlag class
|
||||
test('FeatureFlag class methods work correctly', () => {
|
||||
const featureFlag = new FeatureFlag();
|
||||
|
||||
featureFlag.setPayload('payload');
|
||||
expect(featureFlag.payload).toBe('payload');
|
||||
|
||||
featureFlag.addVariant();
|
||||
expect(featureFlag.variants.length).toBe(2);
|
||||
|
||||
featureFlag.removeVariant(1);
|
||||
expect(featureFlag.variants.length).toBe(1);
|
||||
|
||||
featureFlag.redistributeVariants();
|
||||
expect(featureFlag.variants[0].rolloutPercentage).toBe(100);
|
||||
|
||||
featureFlag.addCondition();
|
||||
expect(featureFlag.conditions.length).toBe(2);
|
||||
|
||||
featureFlag.removeCondition(1);
|
||||
expect(featureFlag.conditions.length).toBe(1);
|
||||
|
||||
featureFlag.setFlagKey('flagKey');
|
||||
expect(featureFlag.flagKey).toBe('flagKey');
|
||||
|
||||
featureFlag.setDescription('description');
|
||||
expect(featureFlag.description).toBe('description');
|
||||
|
||||
featureFlag.setIsPersist(true);
|
||||
expect(featureFlag.isPersist).toBe(true);
|
||||
|
||||
featureFlag.setIsSingleOption(true);
|
||||
expect(featureFlag.isSingleOption).toBe(true);
|
||||
|
||||
featureFlag.setIsEnabled(true);
|
||||
expect(featureFlag.isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
90
frontend/tests/featureFlagsStore.test.js
Normal file
90
frontend/tests/featureFlagsStore.test.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { jest, test, expect, describe } from '@jest/globals';
|
||||
import FeatureFlag from 'App/mstore/types/FeatureFlag';
|
||||
import FeatureFlagsStore from 'App/mstore/FeatureFlagsStore';
|
||||
|
||||
const mockFflagsService = {
|
||||
fetchFlags: jest.fn(),
|
||||
createFlag: jest.fn(),
|
||||
updateFlag: jest.fn(),
|
||||
deleteFlag: jest.fn(),
|
||||
getFlag: jest.fn(),
|
||||
};
|
||||
|
||||
// not working
|
||||
jest.mock('App/services', () => {
|
||||
return {
|
||||
fflagsService: mockFflagsService,
|
||||
};
|
||||
});
|
||||
|
||||
// working fine?
|
||||
jest.mock('App/mstore/types/FeatureFlag', () => {
|
||||
class FakeClass {
|
||||
constructor(data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
toJS() {
|
||||
return jest.fn(() => this)
|
||||
}
|
||||
}
|
||||
return FakeClass;
|
||||
})
|
||||
|
||||
describe('FeatureFlagsStore', () => {
|
||||
test('should fetch flags', async () => {
|
||||
const mockFlags = [{ featureFlagId: 1 }, { featureFlagId: 2 }];
|
||||
mockFflagsService.fetchFlags.mockResolvedValueOnce({ list: mockFlags });
|
||||
const store = new FeatureFlagsStore(mockFflagsService);
|
||||
|
||||
await store.fetchFlags();
|
||||
|
||||
expect(store.flags.length).toBe(mockFlags.length);
|
||||
expect(store.flags[0].featureFlagId).toBe(mockFlags[0].featureFlagId);
|
||||
expect(store.flags[1].featureFlagId).toBe(mockFlags[1].featureFlagId);
|
||||
});
|
||||
|
||||
test('should create a flag', async () => {
|
||||
const mockFlag = { featureFlagId: 3 };
|
||||
mockFflagsService.createFlag.mockResolvedValueOnce(mockFlag);
|
||||
const store = new FeatureFlagsStore(mockFflagsService);
|
||||
store.currentFflag = new FeatureFlag();
|
||||
|
||||
await store.createFlag();
|
||||
|
||||
expect(store.flags.length).toBe(1);
|
||||
expect(store.flags[0].featureFlagId).toBe(mockFlag.featureFlagId);
|
||||
});
|
||||
|
||||
test('should update a flag', async () => {
|
||||
const mockFlag = { featureFlagId: 4 };
|
||||
mockFflagsService.updateFlag.mockResolvedValueOnce(mockFlag);
|
||||
const store = new FeatureFlagsStore(mockFflagsService);
|
||||
store.currentFflag = new FeatureFlag();
|
||||
|
||||
await store.updateFlag();
|
||||
|
||||
expect(store.currentFflag.featureFlagId).toBe(mockFlag.featureFlagId);
|
||||
});
|
||||
|
||||
test('should delete a flag', async () => {
|
||||
const mockFlagId = 5;
|
||||
mockFflagsService.deleteFlag.mockResolvedValueOnce();
|
||||
const store = new FeatureFlagsStore(mockFflagsService);
|
||||
store.flags = [new FeatureFlag({ featureFlagId: mockFlagId })];
|
||||
|
||||
await store.deleteFlag(mockFlagId);
|
||||
|
||||
expect(store.flags.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should fetch a flag', async () => {
|
||||
const mockFlag = { featureFlagId: 6 };
|
||||
mockFflagsService.getFlag.mockResolvedValueOnce(mockFlag);
|
||||
const store = new FeatureFlagsStore(mockFflagsService);
|
||||
|
||||
await store.fetchFlag(mockFlag.featureFlagId);
|
||||
|
||||
expect(store.currentFflag.featureFlagId).toBe(mockFlag.featureFlagId);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,12 +3,12 @@ import AttributeSender from '../main/modules/attributeSender.js'
|
|||
import { describe, expect, test, jest, beforeEach, afterEach } from '@jest/globals'
|
||||
|
||||
describe('AttributeSender', () => {
|
||||
let attributeSender
|
||||
let appMock
|
||||
let attributeSender: AttributeSender
|
||||
let appMock: any
|
||||
|
||||
beforeEach(() => {
|
||||
appMock = {
|
||||
send: (...args) => args,
|
||||
send: (...args: any[]) => args,
|
||||
}
|
||||
attributeSender = new AttributeSender(appMock)
|
||||
})
|
||||
|
|
@ -22,6 +22,7 @@ describe('AttributeSender', () => {
|
|||
const id = 1
|
||||
const name = 'color'
|
||||
const value = 'red'
|
||||
// @ts-ignore
|
||||
const expectedMessage = [Type.SetNodeAttributeDict, id, 1, 2]
|
||||
|
||||
attributeSender.sendSetAttribute(id, name, value)
|
||||
|
|
@ -39,6 +40,7 @@ describe('AttributeSender', () => {
|
|||
|
||||
expect(sendSpy).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
// @ts-ignore
|
||||
Type.SetNodeAttributeDict,
|
||||
id,
|
||||
expect.any(Number),
|
||||
|
|
@ -55,6 +57,7 @@ describe('AttributeSender', () => {
|
|||
|
||||
attributeSender.sendSetAttribute(id, name, value)
|
||||
|
||||
// @ts-ignore
|
||||
expect(sendSpy).toHaveBeenCalledWith([Type.StringDict, expect.any(Number), name])
|
||||
})
|
||||
|
||||
|
|
@ -70,6 +73,7 @@ describe('AttributeSender', () => {
|
|||
// 2 attributes + 1 stringDict name + 1 stringDict value
|
||||
expect(sendSpy).toHaveBeenCalledTimes(4)
|
||||
expect(sendSpy).toHaveBeenCalledWith(
|
||||
// @ts-ignore
|
||||
expect.not.arrayContaining([Type.StringDict, expect.any(Number), name]),
|
||||
)
|
||||
})
|
||||
|
|
@ -87,6 +91,7 @@ describe('AttributeSender', () => {
|
|||
// (attribute + stringDict name + stringDict value) * 2 = 6
|
||||
expect(sendSpy).toHaveBeenCalledTimes(6)
|
||||
expect(sendSpy).toHaveBeenCalledWith(
|
||||
// @ts-ignore
|
||||
expect.arrayContaining([Type.StringDict, expect.any(Number), name]),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
185
tracker/tracker/src/tests/session.unit.test.ts
Normal file
185
tracker/tracker/src/tests/session.unit.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { jest, test, describe, beforeEach, afterEach, expect } from '@jest/globals'
|
||||
import Session from '../main/app/Session'
|
||||
import App from '../main/app/index.js'
|
||||
import { generateRandomId } from '../main/utils.js'
|
||||
|
||||
jest.mock('../main/app/index.js') // Mock the App class
|
||||
jest.mock('../main/utils.js') // Mock the generateRandomId function
|
||||
|
||||
describe('Session', () => {
|
||||
let session: any
|
||||
let mockApp
|
||||
let mockSessionStorage: any
|
||||
let mockOptions: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessionStorage = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
}
|
||||
mockApp = {
|
||||
sessionStorage: mockSessionStorage,
|
||||
options: {
|
||||
ingestPoint: 'test',
|
||||
},
|
||||
}
|
||||
mockApp.sessionStorage = mockSessionStorage
|
||||
mockOptions = {
|
||||
session_token_key: 'token_key',
|
||||
session_pageno_key: 'pageno_key',
|
||||
session_tabid_key: 'tabid_key',
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
generateRandomId.mockReturnValue('random_id')
|
||||
|
||||
session = new Session(mockApp as unknown as App, mockOptions)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('creates a new Session with default values', () => {
|
||||
expect(session).toBeDefined()
|
||||
expect(session.getInfo()).toEqual({
|
||||
sessionID: undefined,
|
||||
metadata: {},
|
||||
userID: null,
|
||||
timestamp: 0,
|
||||
projectID: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test('assigns new info correctly', () => {
|
||||
const newInfo = {
|
||||
sessionID: 'new_id',
|
||||
metadata: { key: 'value' },
|
||||
userID: 'user_1',
|
||||
timestamp: 12345,
|
||||
projectID: 'project_1',
|
||||
}
|
||||
session.assign(newInfo)
|
||||
expect(session.getInfo()).toEqual(newInfo)
|
||||
})
|
||||
|
||||
// Test for attachUpdateCallback
|
||||
test('attaches an update callback correctly', () => {
|
||||
const callback = jest.fn()
|
||||
session.attachUpdateCallback(callback)
|
||||
expect(session['callbacks']).toContain(callback)
|
||||
})
|
||||
|
||||
// Test for handleUpdate
|
||||
test('handles update correctly', () => {
|
||||
const newInfo = { userID: 'user_2' }
|
||||
const callback = jest.fn()
|
||||
session.attachUpdateCallback(callback)
|
||||
session['handleUpdate'](newInfo)
|
||||
expect(callback).toHaveBeenCalledWith(newInfo)
|
||||
})
|
||||
|
||||
// Test for setMetadata
|
||||
test('sets metadata correctly', () => {
|
||||
session.setMetadata('key', 'value')
|
||||
expect(session['metadata']).toEqual({ key: 'value' })
|
||||
})
|
||||
|
||||
// Test for setUserID
|
||||
test('sets userID correctly', () => {
|
||||
session.setUserID('user_1')
|
||||
expect(session['userID']).toEqual('user_1')
|
||||
})
|
||||
|
||||
// Test for setUserInfo
|
||||
test('sets user info correctly', () => {
|
||||
const userInfo = {
|
||||
userBrowser: 'Chrome',
|
||||
userCity: 'San Francisco',
|
||||
userCountry: 'USA',
|
||||
userDevice: 'Desktop',
|
||||
userOS: 'Windows',
|
||||
userState: 'CA',
|
||||
}
|
||||
session.setUserInfo(userInfo)
|
||||
expect(session.userInfo).toEqual(userInfo)
|
||||
})
|
||||
|
||||
// Test for getPageNumber
|
||||
test('gets page number correctly', () => {
|
||||
mockSessionStorage.getItem.mockReturnValue('2')
|
||||
const pageNo = session.getPageNumber()
|
||||
expect(pageNo).toEqual(2)
|
||||
})
|
||||
|
||||
// Test for incPageNo
|
||||
test('increments page number correctly', () => {
|
||||
mockSessionStorage.getItem.mockReturnValue('2')
|
||||
const pageNo = session.incPageNo()
|
||||
expect(pageNo).toEqual(3)
|
||||
})
|
||||
|
||||
// Test for getSessionToken
|
||||
test('gets session token correctly', () => {
|
||||
mockSessionStorage.getItem.mockReturnValue('token_1')
|
||||
const token = session.getSessionToken()
|
||||
expect(token).toEqual('token_1')
|
||||
})
|
||||
|
||||
// Test for setSessionToken
|
||||
test('sets session token correctly', () => {
|
||||
session.setSessionToken('token_1')
|
||||
expect(mockSessionStorage.setItem).toHaveBeenCalledWith(
|
||||
mockOptions.session_token_key,
|
||||
'token_1',
|
||||
)
|
||||
})
|
||||
|
||||
// Test for applySessionHash
|
||||
test('applies session hash correctly', () => {
|
||||
const hash = '1&token_1'
|
||||
session.applySessionHash(hash)
|
||||
expect(mockSessionStorage.setItem).toHaveBeenCalledWith(
|
||||
mockOptions.session_token_key,
|
||||
'token_1',
|
||||
)
|
||||
expect(mockSessionStorage.setItem).toHaveBeenCalledWith(mockOptions.session_pageno_key, '1')
|
||||
})
|
||||
|
||||
// Test for getSessionHash
|
||||
test('gets session hash correctly', () => {
|
||||
mockSessionStorage.getItem.mockReturnValueOnce('1').mockReturnValueOnce('token_1')
|
||||
const hash = session.getSessionHash()
|
||||
expect(hash).toEqual('1&token_1')
|
||||
})
|
||||
|
||||
// Test for getTabId
|
||||
test('gets tabId correctly', () => {
|
||||
expect(session.getTabId()).toEqual('random_id')
|
||||
})
|
||||
|
||||
// Test for createTabId
|
||||
test('creates tabId correctly', () => {
|
||||
mockSessionStorage.getItem.mockReturnValueOnce(null).mockReturnValueOnce('random_id')
|
||||
session['createTabId']()
|
||||
expect(session.getTabId()).toEqual('random_id')
|
||||
expect(mockSessionStorage.setItem).toHaveBeenCalledWith(
|
||||
mockOptions.session_tabid_key,
|
||||
'random_id',
|
||||
)
|
||||
})
|
||||
|
||||
// Test for reset
|
||||
test('resets session correctly', () => {
|
||||
session.reset()
|
||||
expect(session.getInfo()).toEqual({
|
||||
sessionID: undefined,
|
||||
metadata: {},
|
||||
userID: null,
|
||||
timestamp: 0,
|
||||
projectID: undefined,
|
||||
})
|
||||
expect(mockSessionStorage.removeItem).toHaveBeenCalledWith(mockOptions.session_token_key)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue