import * as React from 'react'; import { render, RenderResult, act } from '@testing-library/react'; /** Mocks */ import { mockSdk, Event } from './testUtils/mockSplitFactory'; jest.mock('@splitsoftware/splitio/client', () => { return { SplitFactory: mockSdk() }; }); import { SplitFactory } from '@splitsoftware/splitio/client'; import { sdkBrowser } from './testUtils/sdkConfigs'; import { getStatus } from '../utils'; import { newSplitFactoryLocalhostInstance } from './testUtils/utils'; import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; /** Test target */ import { ISplitTreatmentsChildProps, ISplitTreatmentsProps, ISplitClientProps } from '../types'; import { SplitTreatments } from '../SplitTreatments'; import { SplitClient } from '../SplitClient'; import { SplitFactoryProvider } from '../SplitFactoryProvider'; import { useSplitTreatments } from '../useSplitTreatments'; describe('SplitTreatments', () => { const featureFlagNames = ['split1', 'split2']; const flagSets = ['set1', 'set2']; it('passes control treatments (empty object if flagSets is provided) if the SDK is not operational.', () => { render( {() => { return (
{({ treatments }: ISplitTreatmentsChildProps) => { expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); return null; }} {({ treatments }: ISplitTreatmentsChildProps) => { expect(treatments).toEqual({}); return null; }}
); }}
); }); it('passes as treatments prop the value returned by the method "client.getTreatmentsWithConfig(ByFlagSets)" if the SDK is ready.', () => { const outerFactory = SplitFactory(sdkBrowser); (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); render( {({ factory, isReady }) => { expect(getStatus(outerFactory.client()).isReady).toBe(isReady); expect(isReady).toBe(true); return ( <> {({ treatments, isReady: isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }: ISplitTreatmentsChildProps) => { const clientMock: any = factory?.client(); expect(clientMock.getTreatmentsWithConfig.mock.calls.length).toBe(1); expect(treatments).toBe(clientMock.getTreatmentsWithConfig.mock.results[0].value); expect(featureFlagNames).toBe(clientMock.getTreatmentsWithConfig.mock.calls[0][0]); expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, true, false, false, false, getStatus(outerFactory.client()).lastUpdate]); return null; }} {({ treatments }: ISplitTreatmentsChildProps) => { const clientMock: any = factory?.client(); expect(clientMock.getTreatmentsWithConfigByFlagSets.mock.calls.length).toBe(1); expect(treatments).toBe(clientMock.getTreatmentsWithConfigByFlagSets.mock.results[0].value); expect(flagSets).toBe(clientMock.getTreatmentsWithConfigByFlagSets.mock.calls[0][0]); return null; }} ); }} ); }); test('throws error if invoked outside of SplitFactoryProvider.', () => { expect(() => { render( {() => null} ); }).toThrow(EXCEPTION_NO_SFP); }); /** * Input validation: sanitize invalid feature flag names and return control while the SDK is not ready. */ it('Input validation: invalid names are sanitized.', () => { render( {() => { return ( <> {/* @ts-expect-error Test error handling */} {({ treatments }: ISplitTreatmentsChildProps) => { expect(treatments).toEqual({}); return null; }} {/* @ts-expect-error Test error handling */} {({ treatments }: ISplitTreatmentsChildProps) => { expect(treatments).toEqual({}); return null; }} {/* @ts-expect-error Test error handling */} {({ treatments }: ISplitTreatmentsChildProps) => { expect(treatments).toEqual({ flag_1: CONTROL_WITH_CONFIG }); return null; }} ); }} ); }); test('ignores flagSets if both names and flagSets params are provided.', () => { render( {/* @ts-expect-error flagSets and names are mutually exclusive */} {({ treatments }) => { expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); return null; }} ); }); test('returns the treatments from the client at Split context updated by SplitClient, or control if the client is not operational.', async () => { const outerFactory = SplitFactory(sdkBrowser); const client: any = outerFactory.client('user2'); const attributes = { att1: 'att1' }; let treatments: SplitIO.TreatmentsWithConfig; render( {React.createElement(() => { treatments = useSplitTreatments({ names: featureFlagNames, attributes }).treatments; return null; })} ); // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatmentsWithConfig` method expect(client.getTreatmentsWithConfig).not.toBeCalled(); expect(treatments!).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); // once operational (SDK_READY_FROM_CACHE), it evaluates feature flags act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); expect(client.getTreatmentsWithConfig).toBeCalledWith(featureFlagNames, attributes, undefined); expect(client.getTreatmentsWithConfig).toHaveReturnedWith(treatments!); }); }); let renderTimes = 0; /** * Tests for asserting that client.getTreatmentsWithConfig and client.getTreatmentsWithConfigByFlagSets are not called unnecessarily when using SplitTreatments and useSplitTreatments. */ describe.each([ ({ names, flagSets, attributes, updateOnSdkUpdate }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes, updateOnSdkUpdate?: boolean }) => ( // @ts-expect-error names and flagSets are mutually exclusive {() => { renderTimes++; return null; }} ), ({ names, flagSets, attributes, updateOnSdkUpdate }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes, updateOnSdkUpdate?: boolean }) => { // @ts-expect-error names and flagSets are mutually exclusive useSplitTreatments({ names, flagSets, attributes, updateOnSdkUpdate }); renderTimes++; return null; } ])('SplitTreatments & useSplitTreatments optimization', (InnerComponent) => { let outerFactory = SplitFactory(sdkBrowser); (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); function Component({ names, flagSets, attributes, splitKey, clientAttributes, updateOnSdkUpdate }: { names?: ISplitTreatmentsProps['names'] flagSets?: ISplitTreatmentsProps['flagSets'] attributes: ISplitTreatmentsProps['attributes'] splitKey: ISplitClientProps['splitKey'] clientAttributes?: ISplitClientProps['attributes'], updateOnSdkUpdate?: boolean }) { return ( ); } const names = ['split1', 'split2']; const flagSets = ['set1', 'set2']; const attributes = { att1: 'att1' }; const splitKey = sdkBrowser.core.key; let wrapper: RenderResult; beforeEach(() => { renderTimes = 0; (outerFactory.client().getTreatmentsWithConfig as jest.Mock).mockClear(); wrapper = render(); }) afterEach(() => { wrapper.unmount(); // unmount to remove event listener from factory }) it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names, flagSets and attributes are the same object.', () => { wrapper.rerender(); expect(renderTimes).toBe(2); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); }); it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names, flagSets and attributes are equals (shallow comparison).', () => { wrapper.rerender(); expect(renderTimes).toBe(2); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); }); it('rerenders and re-evaluates feature flags if names are not equals (shallow array comparison).', () => { wrapper.rerender(); expect(renderTimes).toBe(2); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); }); it('rerenders and re-evaluates feature flags if flag sets are not equals (shallow array comparison).', () => { wrapper.rerender(); wrapper.rerender(); expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(1); wrapper.rerender(); expect(renderTimes).toBe(4); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(2); }); it('rerenders and re-evaluates feature flags if attributes are not equals (shallow object comparison).', () => { const attributesRef = { ...attributes, att2: 'att2' }; wrapper.rerender(); expect(renderTimes).toBe(2); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); // If passing same reference but mutated (bad practice), the component re-renders but doesn't re-evaluate feature flags attributesRef.att2 = 'att2_val2'; wrapper.rerender(); expect(renderTimes).toBe(3); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); }); it('rerenders and re-evaluates feature flags if lastUpdate timestamp changes (e.g., SDK_UPDATE event).', () => { expect(renderTimes).toBe(1); // State update and split evaluation act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE)); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); // State update after destroy doesn't re-evaluate because the sdk is not operational (outerFactory as any).client().destroy(); wrapper.rerender(); // Updates were batched as a single render, due to automatic batching https://reactjs.org/blog/2022/03/29/react-v18.html#new-feature-automatic-batching expect(renderTimes).toBe(3); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); // Restore the client to be READY (outerFactory as any).client().__restore(); (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); }); it('rerenders and does not re-evaluate feature flags if lastUpdate timestamp does not change (e.g., SDK_UPDATE event but `updateOnSdkUpdate` false).', () => { wrapper.rerender(); expect(renderTimes).toBe(2); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); // SDK_UPDATE doesn't re-evaluate due to updateOnSdkUpdate false act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE)); expect(renderTimes).toBe(3); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); }); it('rerenders and re-evaluates feature flags if client changes.', async () => { wrapper.rerender(); await act(() => (outerFactory as any).client('otherKey').__emitter__.emit(Event.SDK_READY)); // Initial render + 2 renders (in 3 updates) -> automatic batching https://reactjs.org/blog/2022/03/29/react-v18.html#new-feature-automatic-batching expect(renderTimes).toBe(3); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); expect(outerFactory.client('otherKey').getTreatmentsWithConfig).toBeCalledTimes(1); }); it('rerenders and re-evaluate feature flags when Split context changes (in both SplitFactoryProvider and SplitClient components).', async () => { // changes in SplitContext implies that either the factory, the client (user key), or its status changed, what might imply a change in treatments const outerFactory = SplitFactory(sdkBrowser); let renderTimesComp1 = 0; let renderTimesComp2 = 0; // test context updates on SplitFactoryProvider render( {() => { renderTimesComp1++; return null; }} ); // test context updates on SplitClient render( {() => { renderTimesComp2++; return null; }} ); expect(renderTimesComp1).toBe(1); expect(renderTimesComp2).toBe(1); act(() => { (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE); (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY_FROM_CACHE); }); expect(renderTimesComp1).toBe(2); expect(renderTimesComp2).toBe(1); // updateOnSdkReadyFromCache === false, in second component act(() => { (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT); (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY_TIMED_OUT); }); expect(renderTimesComp1).toBe(3); expect(renderTimesComp2).toBe(2); act(() => { (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY); }); expect(renderTimesComp1).toBe(3); // updateOnSdkReady === false, in first component expect(renderTimesComp2).toBe(3); act(() => { (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE); (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_UPDATE); }); expect(renderTimesComp1).toBe(4); expect(renderTimesComp2).toBe(4); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(3); // renderTimes - 1, for the 1st render where SDK is not operational expect(outerFactory.client('user2').getTreatmentsWithConfig).toBeCalledTimes(3); // idem }); it('rerenders and re-evaluates feature flags if client attributes changes.', (done) => { const originalFactory = outerFactory; outerFactory = newSplitFactoryLocalhostInstance(); const client = outerFactory.client('emma2'); const clientSpy = { getTreatmentsWithConfig: jest.spyOn(client, 'getTreatmentsWithConfig') } client.on(client.Event.SDK_READY, () => { wrapper = render(); expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(1); wrapper.rerender(); expect(renderTimes).toBe(3); expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(2); wrapper.rerender(); expect(renderTimes).toBe(4); expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(3); wrapper.rerender(); expect(renderTimes).toBe(5); expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(3); // not called again. clientAttributes object is shallow equal outerFactory = originalFactory; client.destroy().then(done) }) }); });