diff --git a/README.md b/README.md
index d2b23aa2..934fb2b0 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,26 @@
+[](https://github.com/flagship-io/flagship-ts-sdk/actions/workflows/ci_push.yml) [](https://codecov.io/gh/flagship-io/flagship-ts-sdk) [](https://badge.fury.io/js/@flagship.io%2Fjs-sdk)
+
## About Flagship
-
+
-[Flagship by AB Tasty](https://www.flagship.io/) is a feature flagging platform for modern engineering and product teams. It eliminates the risks of future releases by separating code deployments from these releases :bulb: With Flagship, you have full control over the release process. You can:
+[Flagship by AB Tasty](https://www.abtasty.com) is a feature flagging platform for modern engineering and product teams. It eliminates the risks of future releases by separating code deployments from these releases :bulb: With Flagship, you have full control over the release process. You can:
-- Switch features on or off through remote config.
-- Automatically roll-out your features gradually to monitor performance and gather feedback from your most relevant users.
-- Roll back any feature should any issues arise while testing in production.
-- Segment users by granting access to a feature based on certain user attributes.
-- Carry out A/B tests by easily assigning feature variations to groups of users.
-
-
-
- Flagship also allows you to choose whatever implementation method works for you from our many available SDKs or directly through a REST API. Additionally, our architecture is based on multi-cloud providers that offer high performance and highly-scalable managed services.
-
- **To learn more:**
-
-- [Solution overview](https://www.flagship.io/#showvideo) - A 5mn video demo :movie_camera:
-- [Documentation](https://docs.abtasty.com/server-side/sdks/react-native) - Our dev portal with guides, how tos, API and SDK references
-- [Sign up for a free trial](https://www.flagship.io/sign-up/) - Create your free account
-- [Guide to feature flagging](https://www.flagship.io/feature-flags/) - Everyhting you need to know about feature flag related use cases
-- [Blog](https://www.flagship.io/blog/) - Additional resources about release management
+- Switch features on or off through remote config.
+- Automatically roll-out your features gradually to monitor performance and gather feedback from your most relevant users.
+- Roll back any feature should any issues arise while testing in production.
+- Segment users by granting access to a feature based on certain user attributes.
+- Carry out A/B tests by easily assigning feature variations to groups of users.
+
+
+ Flagship also allows you to choose whatever implementation method works for you from our many available SDKs or directly through a REST API. Additionally, our architecture is based on multi-cloud providers that offer high performance and highly-scalable managed services.
+
+ **To learn more:**
+
+- [Solution overview](https://www.abtasty.com/feature-experimentation/) - Discover how Flagship can help you manage your releases and run experiments in production
+- [Documentation](https://docs.abtasty.com/server-side/sdks/sdk-overview) - Our dev portal with guides, how tos, API and SDK references
+- [Sign up for a free trial](https://www.abtasty.com/get-a-demo/) - Try out Flagship for free and see how it can help you manage your releases and run experiments in production
+- [Guide to feature flagging](https://docs.abtasty.com/feature-experimentation-and-rollout) - Everyhting you need to know about feature flag related use cases
+- [Blog](https://www.abtasty.com/resources/) - Additional resources about release management
diff --git a/package.json b/package.json
index 36fae428..217df29c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@flagship.io/react-native-sdk",
- "version": "5.0.3",
+ "version": "5.1.0",
"description": "Flagship SDK for React Native",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -22,9 +22,6 @@
"type": "git",
"url": "git+https://github.com/flagship-io/flagship-react-native-sdk.git"
},
- "dependencies": {
- "@flagship.io/react-sdk": "^5.2.2"
- },
"peerDependencies": {
"@react-native-async-storage/async-storage": ">=1.17.0",
"react-native": ">=0.60.0"
@@ -75,5 +72,8 @@
"ts-jest": "^29.1.0",
"typescript": "^5.0.4"
},
- "packageManager": "yarn@4.9.0+sha512.5f5f00843eae735dfab6f0442524603e987ceca55e98b36eb0120f4e58908e5b1406545321e46627dca97d15d562f23dc13fb96cabd4e6bc92d379f619001e4e"
+ "packageManager": "yarn@4.9.0+sha512.5f5f00843eae735dfab6f0442524603e987ceca55e98b36eb0120f4e58908e5b1406545321e46627dca97d15d562f23dc13fb96cabd4e6bc92d379f619001e4e",
+ "dependencies": {
+ "@flagship.io/react-sdk": "^5.2.3"
+ }
}
diff --git a/src/FlagshipProvider.tsx b/src/FlagshipProvider.tsx
index 7bbda2c7..0542e6c5 100644
--- a/src/FlagshipProvider.tsx
+++ b/src/FlagshipProvider.tsx
@@ -14,7 +14,13 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import { version as SDK_VERSION } from './sdkVersion';
import { TouchCaptureProvider } from './TouchCaptureProvider';
-import { CLIENT_CACHE_KEY, DEFAULT_POOL_MAX_SIZE, DEFAULT_TIME_INTERVAL, SDK_FIRST_TIME_INIT } from './Constant';
+import {
+ CLIENT_CACHE_KEY,
+ DEFAULT_POOL_MAX_SIZE,
+ DEFAULT_TIME_INTERVAL,
+ SDK_FIRST_TIME_INIT
+} from './Constant';
+import { ABTastyQAProvider } from './qaAssistant/ABTastyQAProvider';
export interface FlagshipProviderProps
extends Omit<
@@ -121,9 +127,12 @@ const FlagshipProviderFunc = ({
);
}, [props.trackingManagerConfig]);
+ const isQAModeEnabled = props.isQAModeEnabled || false;
+
return (
- {children}
+
+ {children}
+
);
};
diff --git a/src/index.ts b/src/index.ts
index 43ba1c94..b5886494 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,6 +6,10 @@ export { FlagshipProvider } from './FlagshipProvider'
export type { FlagshipProviderProps } from './FlagshipProvider'
+export { useABTastyQA } from './qaAssistant/hooks'
+
+export { ABTastyQA } from './type'
+
export { useFlagship, UseFlagshipOutput } from './FlagshipHooks'
diff --git a/src/qaAssistant/ABTastyQAContext.ts b/src/qaAssistant/ABTastyQAContext.ts
new file mode 100644
index 00000000..eda0ac3e
--- /dev/null
+++ b/src/qaAssistant/ABTastyQAContext.ts
@@ -0,0 +1,6 @@
+
+import { createContext } from "react";
+import type { ABTastyQA } from "../type";
+
+
+export const ABTastyQAContext = createContext(null);
\ No newline at end of file
diff --git a/src/qaAssistant/ABTastyQAProvider.tsx b/src/qaAssistant/ABTastyQAProvider.tsx
new file mode 100644
index 00000000..d64f78bd
--- /dev/null
+++ b/src/qaAssistant/ABTastyQAProvider.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { ABTastyQAEventBus } from '@flagship.io/react-sdk';
+
+import { useMemo } from 'react';
+import { ABTastyQAContext } from './ABTastyQAContext';
+
+type ABTastyQAProviderProps = {
+ children: React.ReactNode;
+ isQAModeEnabled?: boolean;
+ envId?: string;
+ apiKey?: string;
+};
+
+export function ABTastyQAProvider({
+ children,
+ isQAModeEnabled,
+ envId,
+ apiKey
+}: ABTastyQAProviderProps) {
+ const ProviderValue = useMemo(
+ () => ({ ABTastyQAEventBus, isQAModeEnabled, envId, apiKey }),
+ [isQAModeEnabled, envId, apiKey]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/qaAssistant/hooks.ts b/src/qaAssistant/hooks.ts
new file mode 100644
index 00000000..86dc721f
--- /dev/null
+++ b/src/qaAssistant/hooks.ts
@@ -0,0 +1,6 @@
+import { useContext } from "react";
+import { ABTastyQAContext } from "./ABTastyQAContext";
+
+export function useABTastyQA() {
+ return useContext(ABTastyQAContext);
+}
\ No newline at end of file
diff --git a/src/sdkVersion.ts b/src/sdkVersion.ts
index 1e2ca71a..adac1e96 100644
--- a/src/sdkVersion.ts
+++ b/src/sdkVersion.ts
@@ -1,2 +1,2 @@
// Generated by genversion.
-export const version = '5.0.3'
+export const version = '5.1.0'
diff --git a/src/type.ts b/src/type.ts
index 7840f9d7..c92c4409 100644
--- a/src/type.ts
+++ b/src/type.ts
@@ -1,6 +1,13 @@
-import { IPageView, IVisitorEvent, Visitor } from "@flagship.io/react-sdk";
+import { IPageView, IVisitorEvent, Visitor, ABTastyQAEventBus } from "@flagship.io/react-sdk";
export type VisitorAugmented = typeof Visitor & {
sendEaiPageView: (pageView: IPageView) => void;
sendEaiVisitorEvent: (visitorEvent: IVisitorEvent) => void;
+}
+
+export interface ABTastyQA {
+ ABTastyQAEventBus: typeof ABTastyQAEventBus;
+ isQAModeEnabled?: boolean;
+ envId?: string;
+ apiKey?: string;
}
\ No newline at end of file
diff --git a/test/TouchCaptureProvider.test.tsx b/test/TouchCaptureProvider.test.tsx
index a0b05e31..f08fbc59 100644
--- a/test/TouchCaptureProvider.test.tsx
+++ b/test/TouchCaptureProvider.test.tsx
@@ -4,8 +4,7 @@ import {
render,
fireEvent,
act,
- waitFor,
- userEvent
+ waitFor
} from '@testing-library/react-native';
import { TouchCaptureProvider } from '../src/TouchCaptureProvider';
import { Flagship, IVisitorEvent } from '@flagship.io/react-sdk';
@@ -23,20 +22,18 @@ jest.mock('@flagship.io/react-sdk', () => {
});
function sleep(time: number): Promise {
- return new Promise((resolve, reject) => {
+ return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, time);
});
}
-describe('TouchCaptureProvider', () => {
+describe('TouchCaptureProvider - Basic Rendering', () => {
const mockSendEaiVisitorEvent = jest.fn();
const mockOnEAICollectStatusChange =
jest.fn<(fn: (status: boolean) => void) => void>();
- const fixedTimestamp = 254889889; // 1978-05-25T11:48:09.889Z
-
beforeEach(() => {
jest.clearAllMocks();
const mockVisitor = {
@@ -55,11 +52,7 @@ describe('TouchCaptureProvider', () => {
});
});
- afterEach(() => {
- jest.useRealTimers();
- });
-
- it('renders children correctly', () => {
+ it('should render children correctly within the provider', () => {
const { getByText } = render(
@@ -70,7 +63,72 @@ describe('TouchCaptureProvider', () => {
expect(getByText('Child Component')).toBeTruthy();
});
- it('calls sendTouchPositionEvent on touch start', async () => {
+ it('should not register EAI callback when visitor is undefined', async () => {
+ (Flagship.getVisitor as jest.Mock).mockReturnValue(undefined);
+
+ render(
+
+
+ Test
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockOnEAICollectStatusChange).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should not register EAI callback when onEAICollectStatusChange is not available', async () => {
+ const mockVisitor = {
+ visitorId: 'testVisitorId',
+ sendEaiVisitorEvent: mockSendEaiVisitorEvent
+ // onEAICollectStatusChange is missing
+ };
+ (Flagship.getVisitor as jest.Mock).mockReturnValue(mockVisitor);
+
+ render(
+
+
+ Test
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockOnEAICollectStatusChange).not.toHaveBeenCalled();
+ });
+ });
+});
+
+describe('TouchCaptureProvider - Touch Position Events', () => {
+ const mockSendEaiVisitorEvent = jest.fn();
+ const mockOnEAICollectStatusChange =
+ jest.fn<(fn: (status: boolean) => void) => void>();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ const mockVisitor = {
+ visitorId: 'testVisitorId',
+ sendEaiVisitorEvent: mockSendEaiVisitorEvent,
+ onEAICollectStatusChange: mockOnEAICollectStatusChange
+ };
+ (Flagship.getVisitor as jest.Mock).mockReturnValue(mockVisitor);
+ Dimensions.get = jest
+ .fn<(dim: 'window' | 'screen') => ScaledSize>()
+ .mockReturnValue({
+ width: 1080,
+ height: 1920,
+ scale: 1,
+ fontScale: 1
+ });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('should send touch position event after timeout when touch starts', async () => {
let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
mockOnEAICollectStatusChange.mockImplementation((fn) => {
onEAICollectStatusChange = fn;
@@ -105,38 +163,63 @@ describe('TouchCaptureProvider', () => {
},
{ timeout: TIMEOUT_DURATION + 100 }
);
+ });
+ it('should send previous touch position immediately when new touch starts', async () => {
+ let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
+ mockOnEAICollectStatusChange.mockImplementation((fn) => {
+ onEAICollectStatusChange = fn;
+ });
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ await waitFor(() => {
+ onEAICollectStatusChange?.(true);
+ });
+
+ // First touch
await act(() => {
fireEvent(getByTestId('test-view'), 'touchStart', {
- nativeEvent: { pageX: 102, pageY: 201 }
+ nativeEvent: { pageX: 100, pageY: 200 }
});
+ });
+
+ await sleep(100); // Wait less than timeout
+
+ // Second touch - should trigger immediate send of first touch
+ await act(() => {
fireEvent(getByTestId('test-view'), 'touchStart', {
- nativeEvent: { pageX: 103, pageY: 202 }
+ nativeEvent: { pageX: 102, pageY: 201 }
});
});
await waitFor(
() => {
- expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(2);
- expect(mockSendEaiVisitorEvent).toHaveBeenNthCalledWith(2, {
+ expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(1);
+ expect(mockSendEaiVisitorEvent).toHaveBeenNthCalledWith(1, {
customerAccountId: 'testEnvId',
visitorId: 'testVisitorId',
currentUrl: '',
- clickPosition: expect.stringMatching(/201,102,[0-9]{5},0;/),
+ clickPosition: expect.stringMatching(/200,100,[0-9]{5},0;/),
screenSize: '1080,1920;'
});
},
{ timeout: 100 }
);
+ // Wait for second touch timeout
await waitFor(
() => {
- expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(3);
- expect(mockSendEaiVisitorEvent).toHaveBeenNthCalledWith(3, {
+ expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(2);
+ expect(mockSendEaiVisitorEvent).toHaveBeenNthCalledWith(2, {
customerAccountId: 'testEnvId',
visitorId: 'testVisitorId',
currentUrl: '',
- clickPosition: expect.stringMatching(/202,103,[0-9]{5},0;/),
+ clickPosition: expect.stringMatching(/201,102,[0-9]{5},0;/),
screenSize: '1080,1920;'
});
},
@@ -144,7 +227,129 @@ describe('TouchCaptureProvider', () => {
);
});
- it('calls sendTouchPathEvent on touch move', async () => {
+ it('should not send touch position event when visitor is unavailable', async () => {
+ let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
+ mockOnEAICollectStatusChange.mockImplementation((fn) => {
+ onEAICollectStatusChange = fn;
+ });
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ await waitFor(() => {
+ onEAICollectStatusChange?.(true);
+ });
+
+ (Flagship.getVisitor as jest.Mock).mockReturnValue(undefined);
+
+ await act(() => {
+ fireEvent(getByTestId('test-view'), 'touchStart', {
+ nativeEvent: { pageX: 100, pageY: 200 }
+ });
+ });
+
+ await sleep(TIMEOUT_DURATION + 100);
+
+ expect(mockSendEaiVisitorEvent).not.toHaveBeenCalled();
+ });
+
+ it('should not send touch position event when sendEaiVisitorEvent method is not available', async () => {
+ let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
+ mockOnEAICollectStatusChange.mockImplementation((fn) => {
+ onEAICollectStatusChange = fn;
+ });
+
+ const mockVisitorWithoutMethod = {
+ visitorId: 'testVisitorId',
+ onEAICollectStatusChange: mockOnEAICollectStatusChange
+ // sendEaiVisitorEvent is missing
+ };
+ (Flagship.getVisitor as jest.Mock).mockReturnValue(mockVisitorWithoutMethod);
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ await waitFor(() => {
+ onEAICollectStatusChange?.(true);
+ });
+
+ await act(() => {
+ fireEvent(getByTestId('test-view'), 'touchStart', {
+ nativeEvent: { pageX: 100, pageY: 200 }
+ });
+ });
+
+ await sleep(TIMEOUT_DURATION + 100);
+
+ expect(mockSendEaiVisitorEvent).not.toHaveBeenCalled();
+ });
+
+ it('should cleanup timeouts when component unmounts', async () => {
+ let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
+ mockOnEAICollectStatusChange.mockImplementation((fn) => {
+ onEAICollectStatusChange = fn;
+ });
+
+ const { getByTestId, unmount } = render(
+
+
+
+ );
+
+ await waitFor(() => {
+ onEAICollectStatusChange?.(true);
+ });
+
+ await act(() => {
+ fireEvent(getByTestId('test-view'), 'touchStart', {
+ nativeEvent: { pageX: 100, pageY: 200 }
+ });
+ fireEvent(getByTestId('test-view'), 'touchMove', {
+ nativeEvent: { pageX: 150, pageY: 250 }
+ });
+ });
+
+ // Unmount immediately - this tests cleanup logic runs without errors
+ unmount();
+
+ // No assertion needed - test passes if no errors occur during cleanup
+ });
+});
+
+describe('TouchCaptureProvider - Touch Path Events', () => {
+ const mockSendEaiVisitorEvent = jest.fn();
+ const mockOnEAICollectStatusChange =
+ jest.fn<(fn: (status: boolean) => void) => void>();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ const mockVisitor = {
+ visitorId: 'testVisitorId',
+ sendEaiVisitorEvent: mockSendEaiVisitorEvent,
+ onEAICollectStatusChange: mockOnEAICollectStatusChange
+ };
+ (Flagship.getVisitor as jest.Mock).mockReturnValue(mockVisitor);
+ Dimensions.get = jest
+ .fn<(dim: 'window' | 'screen') => ScaledSize>()
+ .mockReturnValue({
+ width: 1080,
+ height: 1920,
+ scale: 1,
+ fontScale: 1
+ });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('should send touch path event after timeout when touch moves', async () => {
let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
mockOnEAICollectStatusChange.mockImplementation((fn) => {
onEAICollectStatusChange = fn;
@@ -179,11 +384,26 @@ describe('TouchCaptureProvider', () => {
},
{ timeout: TIMEOUT_DURATION + 100 }
);
+ });
+
+ it('should send touch path immediately when path length exceeds maximum', async () => {
+ let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
+ mockOnEAICollectStatusChange.mockImplementation((fn) => {
+ onEAICollectStatusChange = fn;
+ });
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ await waitFor(() => {
+ onEAICollectStatusChange?.(true);
+ });
await act(() => {
- fireEvent(getByTestId('test-view'), 'touchStart', {
- nativeEvent: { pageX: 100, pageY: 200 }
- });
+ // Send enough touch move events to exceed MAX_CLICK_PATH_LENGTH (1900)
for (let index = 0; index < 136; index++) {
const pageX = 150 + index;
const pageY = 250 + index;
@@ -195,14 +415,12 @@ describe('TouchCaptureProvider', () => {
await waitFor(
() => {
- expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(2);
- expect(mockSendEaiVisitorEvent).toHaveBeenNthCalledWith(2, {
+ expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(1);
+ expect(mockSendEaiVisitorEvent).toHaveBeenNthCalledWith(1, {
customerAccountId: 'testEnvId',
visitorId: 'testVisitorId',
currentUrl: '',
- clickPath: expect.stringMatching(
- /^(?:\d{3},\d{3},\d{5};)+$/
- ),
+ clickPath: expect.stringMatching(/^(?:\d{3},\d{3},\d{5};)+$/),
screenSize: '1080,1920;'
});
},
@@ -210,48 +428,182 @@ describe('TouchCaptureProvider', () => {
);
});
- it('does not attach touch handlers when not collecting EAI data', async () => {
+ it('should reset touch path timeout on each move event', async () => {
+ let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
+ mockOnEAICollectStatusChange.mockImplementation((fn) => {
+ onEAICollectStatusChange = fn;
+ });
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ await waitFor(() => {
+ onEAICollectStatusChange?.(true);
+ });
+
+ await act(() => {
+ fireEvent(getByTestId('test-view'), 'touchMove', {
+ nativeEvent: { pageX: 150, pageY: 250 }
+ });
+ });
+
+ await sleep(TIMEOUT_DURATION - 100);
+
+ await act(() => {
+ fireEvent(getByTestId('test-view'), 'touchMove', {
+ nativeEvent: { pageX: 151, pageY: 251 }
+ });
+ });
+
+ // First event should not have been sent yet
+ expect(mockSendEaiVisitorEvent).not.toHaveBeenCalled();
+
+ await waitFor(
+ () => {
+ expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(1);
+ },
+ { timeout: TIMEOUT_DURATION + 100 }
+ );
+ });
+
+ it('should clear touch coordinates when move occurs shortly after touch start', async () => {
+ let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
+ mockOnEAICollectStatusChange.mockImplementation((fn) => {
+ onEAICollectStatusChange = fn;
+ });
+
const { getByTestId } = render(
);
+ await waitFor(() => {
+ onEAICollectStatusChange?.(true);
+ });
+
await act(() => {
fireEvent(getByTestId('test-view'), 'touchStart', {
nativeEvent: { pageX: 100, pageY: 200 }
});
+ });
+ // Move quickly after start (within timeout)
+ await act(() => {
fireEvent(getByTestId('test-view'), 'touchMove', {
nativeEvent: { pageX: 150, pageY: 250 }
});
});
+ // Wait for path timeout
await waitFor(
() => {
- expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(0);
+ expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(1);
+ // Should only have path event, not position event
+ expect(mockSendEaiVisitorEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ clickPath: expect.stringMatching(/250,150,[0-9]{5};/)
+ })
+ );
+ },
+ { timeout: TIMEOUT_DURATION + 100 }
+ );
+
+ // Wait additional time to ensure no position event was sent
+ await sleep(TIMEOUT_DURATION);
+ expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not send touch path event when visitor is unavailable', async () => {
+ let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
+ mockOnEAICollectStatusChange.mockImplementation((fn) => {
+ onEAICollectStatusChange = fn;
+ });
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ await waitFor(() => {
+ onEAICollectStatusChange?.(true);
+ });
+
+ (Flagship.getVisitor as jest.Mock).mockReturnValue(undefined);
+
+ await act(() => {
+ fireEvent(getByTestId('test-view'), 'touchMove', {
+ nativeEvent: { pageX: 100, pageY: 200 }
+ });
+ });
+
+ await sleep(TIMEOUT_DURATION + 100);
+
+ expect(mockSendEaiVisitorEvent).not.toHaveBeenCalled();
+ });
+
+ it('should handle missing visitorId and envId with fallback empty strings', async () => {
+ let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
+ mockOnEAICollectStatusChange.mockImplementation((fn) => {
+ onEAICollectStatusChange = fn;
+ });
+
+ const mockVisitorWithoutIds = {
+ visitorId: null,
+ sendEaiVisitorEvent: mockSendEaiVisitorEvent,
+ onEAICollectStatusChange: mockOnEAICollectStatusChange
+ };
+ (Flagship.getVisitor as jest.Mock).mockReturnValue(mockVisitorWithoutIds);
+ (Flagship.getConfig as jest.Mock).mockReturnValue({ envId: null });
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ await waitFor(() => {
+ onEAICollectStatusChange?.(true);
+ });
+
+ await act(() => {
+ fireEvent(getByTestId('test-view'), 'touchMove', {
+ nativeEvent: { pageX: 150, pageY: 250 }
+ });
+ });
+
+ await waitFor(
+ () => {
+ expect(mockSendEaiVisitorEvent).toHaveBeenCalledWith({
+ customerAccountId: '',
+ visitorId: '',
+ currentUrl: '',
+ clickPath: expect.any(String),
+ screenSize: '1080,1920;'
+ });
},
{ timeout: TIMEOUT_DURATION + 100 }
);
});
});
-describe('TouchCaptureProvider with undefined visitor', () => {
+describe('TouchCaptureProvider - EAI Data Collection Toggle', () => {
const mockSendEaiVisitorEvent = jest.fn();
const mockOnEAICollectStatusChange =
jest.fn<(fn: (status: boolean) => void) => void>();
beforeEach(() => {
jest.clearAllMocks();
-
const mockVisitor = {
visitorId: 'testVisitorId',
sendEaiVisitorEvent: mockSendEaiVisitorEvent,
onEAICollectStatusChange: mockOnEAICollectStatusChange
};
-
(Flagship.getVisitor as jest.Mock).mockReturnValue(mockVisitor);
-
Dimensions.get = jest
.fn<(dim: 'window' | 'screen') => ScaledSize>()
.mockReturnValue({
@@ -262,27 +614,29 @@ describe('TouchCaptureProvider with undefined visitor', () => {
});
});
- it('renders children correctly', async () => {
- (Flagship.getVisitor as jest.Mock).mockReturnValue(undefined);
- let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
- mockOnEAICollectStatusChange.mockImplementation((fn) => {
- onEAICollectStatusChange = fn;
- });
-
- const { getByText } = render(
+ it('should not capture touch events when EAI data collection is disabled', async () => {
+ const { getByTestId } = render(
-
- Child Component
-
+
);
- await waitFor(() => {
- expect(mockOnEAICollectStatusChange).toHaveBeenCalledTimes(0);
+ await act(() => {
+ fireEvent(getByTestId('test-view'), 'touchStart', {
+ nativeEvent: { pageX: 100, pageY: 200 }
+ });
+
+ fireEvent(getByTestId('test-view'), 'touchMove', {
+ nativeEvent: { pageX: 150, pageY: 250 }
+ });
});
+
+ await sleep(TIMEOUT_DURATION + 100);
+
+ expect(mockSendEaiVisitorEvent).not.toHaveBeenCalled();
});
- it('touch start', async () => {
+ it('should start capturing touch events when EAI data collection is enabled', async () => {
let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
mockOnEAICollectStatusChange.mockImplementation((fn) => {
onEAICollectStatusChange = fn;
@@ -294,29 +648,37 @@ describe('TouchCaptureProvider with undefined visitor', () => {
);
+ // Initially disabled, touch should not be captured
+ await act(() => {
+ fireEvent(getByTestId('test-view'), 'touchStart', {
+ nativeEvent: { pageX: 100, pageY: 200 }
+ });
+ });
+
+ await sleep(100);
+ expect(mockSendEaiVisitorEvent).not.toHaveBeenCalled();
+
+ // Enable collection
await waitFor(() => {
onEAICollectStatusChange?.(true);
});
- (Flagship.getVisitor as jest.Mock).mockReturnValue(undefined);
-
+ // Now touch should be captured
await act(() => {
fireEvent(getByTestId('test-view'), 'touchStart', {
nativeEvent: { pageX: 100, pageY: 200 }
});
});
- await sleep(TIMEOUT_DURATION + 100);
-
await waitFor(
() => {
- expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(0);
+ expect(mockSendEaiVisitorEvent).toHaveBeenCalled();
},
{ timeout: TIMEOUT_DURATION + 100 }
);
});
- it('touch move', async () => {
+ it('should stop capturing touch events when EAI data collection is disabled after being enabled', async () => {
let onEAICollectStatusChange: ((status: boolean) => void) | undefined;
mockOnEAICollectStatusChange.mockImplementation((fn) => {
onEAICollectStatusChange = fn;
@@ -328,25 +690,39 @@ describe('TouchCaptureProvider with undefined visitor', () => {
);
+ // Enable collection
await waitFor(() => {
onEAICollectStatusChange?.(true);
});
- (Flagship.getVisitor as jest.Mock).mockReturnValue(undefined);
-
await act(() => {
- fireEvent(getByTestId('test-view'), 'touchMove', {
+ fireEvent(getByTestId('test-view'), 'touchStart', {
nativeEvent: { pageX: 100, pageY: 200 }
});
});
- await sleep(TIMEOUT_DURATION + 100);
-
await waitFor(
() => {
- expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(0);
+ expect(mockSendEaiVisitorEvent).toHaveBeenCalledTimes(1);
},
{ timeout: TIMEOUT_DURATION + 100 }
);
+
+ mockSendEaiVisitorEvent.mockClear();
+
+ // Disable collection
+ await act(() => {
+ onEAICollectStatusChange?.(false);
+ });
+
+ // Touch should not be captured anymore
+ await act(() => {
+ fireEvent(getByTestId('test-view'), 'touchStart', {
+ nativeEvent: { pageX: 100, pageY: 200 }
+ });
+ });
+
+ await sleep(TIMEOUT_DURATION + 100);
+ expect(mockSendEaiVisitorEvent).not.toHaveBeenCalled();
});
});
diff --git a/test/qaAssistant/ABTastyQAProvider.test.tsx b/test/qaAssistant/ABTastyQAProvider.test.tsx
new file mode 100644
index 00000000..d89b9221
--- /dev/null
+++ b/test/qaAssistant/ABTastyQAProvider.test.tsx
@@ -0,0 +1,157 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import { Text } from 'react-native';
+import { ABTastyQAProvider } from '../../src/qaAssistant/ABTastyQAProvider';
+import { useABTastyQA } from '../../src/qaAssistant/hooks';
+import { ABTastyQAEventBus } from '@flagship.io/react-sdk';
+
+describe('ABTastyQAProvider', () => {
+ it('should render children correctly', () => {
+ const { getByText } = render(
+
+ Test Child
+
+ );
+
+ expect(getByText('Test Child')).toBeTruthy();
+ });
+
+ it('should provide context with default values when no props are passed', () => {
+ let contextValue: any;
+
+ const TestComponent = () => {
+ contextValue = useABTastyQA();
+ return Test;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(contextValue).toBeDefined();
+ expect(contextValue?.ABTastyQAEventBus).toBe(ABTastyQAEventBus);
+ expect(contextValue?.isQAModeEnabled).toBeUndefined();
+ expect(contextValue?.envId).toBeUndefined();
+ expect(contextValue?.apiKey).toBeUndefined();
+ });
+
+ it('should provide context with isQAModeEnabled when passed', () => {
+ let contextValue: any;
+
+ const TestComponent = () => {
+ contextValue = useABTastyQA();
+ return Test;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(contextValue?.isQAModeEnabled).toBe(true);
+ });
+
+ it('should provide context with envId when passed', () => {
+ let contextValue: any;
+
+ const TestComponent = () => {
+ contextValue = useABTastyQA();
+ return Test;
+ };
+
+ const testEnvId = 'test-env-id-123';
+
+ render(
+
+
+
+ );
+
+ expect(contextValue?.envId).toBe(testEnvId);
+ });
+
+ it('should provide context with apiKey when passed', () => {
+ let contextValue: any;
+
+ const TestComponent = () => {
+ contextValue = useABTastyQA();
+ return Test;
+ };
+
+ const testApiKey = 'test-api-key-456';
+
+ render(
+
+
+
+ );
+
+ expect(contextValue?.apiKey).toBe(testApiKey);
+ });
+
+ it('should provide context with all props when passed', () => {
+ let contextValue: any;
+
+ const TestComponent = () => {
+ contextValue = useABTastyQA();
+ return Test;
+ };
+
+ const testEnvId = 'test-env-id-123';
+ const testApiKey = 'test-api-key-456';
+
+ render(
+
+
+
+ );
+
+ expect(contextValue?.ABTastyQAEventBus).toBe(ABTastyQAEventBus);
+ expect(contextValue?.isQAModeEnabled).toBe(true);
+ expect(contextValue?.envId).toBe(testEnvId);
+ expect(contextValue?.apiKey).toBe(testApiKey);
+ });
+
+ it('should memoize the context value correctly', () => {
+ const contextValues: any[] = [];
+
+ const TestComponent = () => {
+ const value = useABTastyQA();
+ contextValues.push(value);
+ return Test;
+ };
+
+ const { rerender } = render(
+
+
+
+ );
+
+ // Rerender with same props
+ rerender(
+
+
+
+ );
+
+ // Should be the same reference
+ expect(contextValues[0]).toBe(contextValues[1]);
+
+ // Rerender with different props
+ rerender(
+
+
+
+ );
+
+ // Should be a different reference
+ expect(contextValues[1]).not.toBe(contextValues[2]);
+ });
+});
diff --git a/test/qaAssistant/hooks.test.tsx b/test/qaAssistant/hooks.test.tsx
new file mode 100644
index 00000000..9b5c0eed
--- /dev/null
+++ b/test/qaAssistant/hooks.test.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import { Text } from 'react-native';
+import { useABTastyQA } from '../../src/qaAssistant/hooks';
+import { ABTastyQAProvider } from '../../src/qaAssistant/ABTastyQAProvider';
+import { ABTastyQAEventBus } from '@flagship.io/react-sdk';
+
+describe('useABTastyQA hook', () => {
+ it('should return null when used outside ABTastyQAProvider', () => {
+ let contextValue: any;
+
+ const TestComponent = () => {
+ contextValue = useABTastyQA();
+ return Test;
+ };
+
+ render();
+
+ expect(contextValue).toBeNull();
+ });
+
+ it('should return context value when used inside ABTastyQAProvider', () => {
+ let contextValue: any;
+
+ const TestComponent = () => {
+ contextValue = useABTastyQA();
+ return Test;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(contextValue).not.toBeNull();
+ expect(contextValue?.isQAModeEnabled).toBe(true);
+ });
+
+ it('should access ABTastyQAEventBus from context', () => {
+ let contextValue: any;
+
+ const TestComponent = () => {
+ contextValue = useABTastyQA();
+ return Test;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(contextValue?.ABTastyQAEventBus).toBe(ABTastyQAEventBus);
+ });
+
+ it('should access all properties from context', () => {
+ let contextValue: any;
+
+ const TestComponent = () => {
+ contextValue = useABTastyQA();
+ return Test;
+ };
+
+ const testEnvId = 'test-env';
+ const testApiKey = 'test-key';
+
+ render(
+
+
+
+ );
+
+ expect(contextValue?.ABTastyQAEventBus).toBe(ABTastyQAEventBus);
+ expect(contextValue?.isQAModeEnabled).toBe(true);
+ expect(contextValue?.envId).toBe(testEnvId);
+ expect(contextValue?.apiKey).toBe(testApiKey);
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index 63469295..91200fc4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1636,15 +1636,15 @@ __metadata:
languageName: node
linkType: hard
-"@flagship.io/js-sdk@npm:^5.1.7":
- version: 5.1.7
- resolution: "@flagship.io/js-sdk@npm:5.1.7"
+"@flagship.io/js-sdk@npm:^5.2.0":
+ version: 5.2.0
+ resolution: "@flagship.io/js-sdk@npm:5.2.0"
dependencies:
events: "npm:^3.3.0"
follow-redirects: "npm:^1.15.9"
node-abort-controller: "npm:^3.1.1"
node-fetch: "npm:^2.6.7"
- checksum: 10c0/b8dedcb22bb1300f4b8c4495bbdadeb3be341c399ff66e9e8c2f31cd1eb946817fd079403dca84cd7ca3bb0af8499f2808ffde14f5317058ae9f81f614879a16
+ checksum: 10c0/cb11aebae792fb02dd892603f4b7cfe2f93ecf289578637095a2c68bab53a4c1e468f5bce8837af4b1696a6a2d0c12f2e3181417fe9f7cf7512f84fd8df0cd40
languageName: node
linkType: hard
@@ -1657,7 +1657,7 @@ __metadata:
"@babel/preset-env": "npm:^7.14.7"
"@babel/preset-react": "npm:^7.14.5"
"@babel/preset-typescript": "npm:^7.14.5"
- "@flagship.io/react-sdk": "npm:^5.2.2"
+ "@flagship.io/react-sdk": "npm:^5.2.3"
"@react-native-async-storage/async-storage": "npm:^1.17.11"
"@react-native-community/eslint-config": "npm:^1.1.0"
"@testing-library/jest-native": "npm:^3.1.0"
@@ -1695,15 +1695,15 @@ __metadata:
languageName: unknown
linkType: soft
-"@flagship.io/react-sdk@npm:^5.2.2":
- version: 5.2.2
- resolution: "@flagship.io/react-sdk@npm:5.2.2"
+"@flagship.io/react-sdk@npm:^5.2.3":
+ version: 5.2.3
+ resolution: "@flagship.io/react-sdk@npm:5.2.3"
dependencies:
- "@flagship.io/js-sdk": "npm:^5.1.7"
+ "@flagship.io/js-sdk": "npm:^5.2.0"
encoding: "npm:^0.1.13"
peerDependencies:
react: ">=16.8.0"
- checksum: 10c0/5756018456ccbb30436d7647310b894cda5f4ffbf9fb7efd0fa5b5871876e6c809d23ea2c759818c457559dc4d79b332a0b8c7da342821ed118712551f0ab3b7
+ checksum: 10c0/5002021532b9e1206b47b9aef3b93061852c08a03b8ea2c07e22c3d2efad55b95e90b5b7b541e9a90f1cda3d3e8cfa61ec24bd969ad1ad8b86ad1be783ca3cdf
languageName: node
linkType: hard
@@ -5989,12 +5989,12 @@ __metadata:
linkType: hard
"follow-redirects@npm:^1.15.9":
- version: 1.15.9
- resolution: "follow-redirects@npm:1.15.9"
+ version: 1.15.11
+ resolution: "follow-redirects@npm:1.15.11"
peerDependenciesMeta:
debug:
optional: true
- checksum: 10c0/5829165bd112c3c0e82be6c15b1a58fa9dcfaede3b3c54697a82fe4a62dd5ae5e8222956b448d2f98e331525f05d00404aba7d696de9e761ef6e42fdc780244f
+ checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343
languageName: node
linkType: hard