Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions .github/workflows/performance-test-runner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,28 @@ jobs:
username: ${{ secrets.BROWSERSTACK_USERNAME }}
access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
project-name: ${{ github.repository }}


- name: Compute BrowserStack Local Identifier
id: bs-local-id
run: |
# Strip any character that is not alphanumeric or a hyphen (covers spaces, /, \, @, etc.)
BUILD_TYPE_SAFE=$(printf '%s' "${{ inputs.build_type }}" | tr -c 'a-zA-Z0-9-' '_')
DEVICE_SAFE=$(printf '%s' "${{ matrix.device.name }}" | tr -c 'a-zA-Z0-9-' '_')
if [ -z "$BUILD_TYPE_SAFE" ]; then
echo "❌ Error: inputs.build_type is empty — cannot build a valid BrowserStack Local identifier"
exit 1
fi
if [ -z "$DEVICE_SAFE" ]; then
echo "❌ Error: matrix.device.name is empty — cannot build a valid BrowserStack Local identifier"
exit 1
fi
echo "value=${{ github.run_id }}-${BUILD_TYPE_SAFE}-${DEVICE_SAFE}" >> "$GITHUB_OUTPUT"

- name: Setup BrowserStack Local
uses: browserstack/github-actions/setup-local@4478e16186f38e5be07721931642e65a028713c3
with:
local-testing: start
local-identifier: ${{ github.run_id }}
local-identifier: ${{ steps.bs-local-id.outputs.value }}
# Use same args for all build types. Do not use --include-hosts for mm-connect:
# bs-local.com requests must be forwarded to localhost; --include-hosts can block them.
local-args: '--force-local --verbose'
Expand Down Expand Up @@ -147,7 +163,7 @@ jobs:
- name: Run Tests
env:
BROWSERSTACK_LOCAL: 'true'
BROWSERSTACK_LOCAL_IDENTIFIER: ${{ github.run_id }}
BROWSERSTACK_LOCAL_IDENTIFIER: ${{ steps.bs-local-id.outputs.value }}
BROWSERSTACK_BUILD_NAME: ${{ inputs.browserstack_build_name }}
working-directory: '.'
run: |
Expand Down
168 changes: 74 additions & 94 deletions app/components/UI/Predict/hooks/usePredictActivity.test.ts
Original file line number Diff line number Diff line change
@@ -1,134 +1,114 @@
import { renderHook, act } from '@testing-library/react-hooks';
import React from 'react';
import { renderHook, waitFor, act } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { usePredictActivity } from './usePredictActivity';
import Engine from '../../../../core/Engine';

// Mock Engine
const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890';

const mockGetActivity = jest.fn();
jest.mock('../../../../core/Engine', () => ({
context: {
PredictController: {
getActivity: jest.fn(),
getActivity: (...args: unknown[]) => mockGetActivity(...args),
},
},
}));

// Mock navigation focus effect without auto-invocation; provide manual trigger
jest.mock('@react-navigation/native', () => {
let focusCb: (() => void) | null = null;
return {
useFocusEffect: (cb: () => void) => {
focusCb = cb;
},
__esModule: true,
__mock: {
invokeFocusEffect: () => {
focusCb?.();
},
},
};
});
jest.mock('../utils/accounts', () => ({
getEvmAccountFromSelectedAccountGroup: jest.fn(() => ({
address: MOCK_ADDRESS,
})),
}));

describe('usePredictActivity', () => {
const mockGetActivity = jest.fn();
const mockEnsurePolygonNetworkExists = jest.fn<Promise<void>, []>();
jest.mock('./usePredictNetworkManagement', () => ({
usePredictNetworkManagement: () => ({
ensurePolygonNetworkExists: mockEnsurePolygonNetworkExists,
}),
}));

beforeEach(() => {
jest.clearAllMocks();
(Engine.context.PredictController.getActivity as jest.Mock) =
mockGetActivity;
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});

afterEach(() => {
jest.clearAllMocks();
});
const Wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);

it('initializes and auto-loads activity on mount', async () => {
const data = [{ id: '1' }];
mockGetActivity.mockResolvedValueOnce(data);
return { Wrapper };
};

const { result, waitForNextUpdate } = renderHook(() =>
usePredictActivity(),
);
describe('usePredictActivity', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetActivity.mockResolvedValue([]);
mockEnsurePolygonNetworkExists.mockResolvedValue(undefined);
});

expect(result.current.isLoading).toBe(true);
expect(result.current.activity).toEqual([]);
expect(result.current.error).toBe(null);
it('fetches activity automatically on mount', async () => {
const { Wrapper } = createWrapper();
const activity = [{ id: '1' }];
mockGetActivity.mockResolvedValueOnce(activity);

await waitForNextUpdate();
const { result } = renderHook(() => usePredictActivity(), {
wrapper: Wrapper,
});

expect(result.current.isLoading).toBe(false);
expect(result.current.activity).toEqual(data);
expect(result.current.error).toBe(null);
expect(mockGetActivity).toHaveBeenCalledWith({
providerId: undefined,
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.data).toEqual(activity);
expect(result.current.error).toBeNull();
expect(mockGetActivity).toHaveBeenCalledWith({ address: MOCK_ADDRESS });
});

it('loads activity when hook is mounted', async () => {
mockGetActivity.mockResolvedValueOnce([]);
it('exposes error when activity fetch fails', async () => {
const { Wrapper } = createWrapper();
mockGetActivity.mockRejectedValueOnce(new Error('Boom'));

const { waitForNextUpdate } = renderHook(() => usePredictActivity());
const { result } = renderHook(() => usePredictActivity(), {
wrapper: Wrapper,
});

await waitForNextUpdate();
await waitFor(() => {
expect(result.current.error).toBeInstanceOf(Error);
});

expect(mockGetActivity).toHaveBeenCalledWith({});
expect(result.current.error?.message).toBe('Boom');
});

it('can refresh with isRefresh=true and sets isRefreshing flag', async () => {
it('uses refetch for refresh behavior', async () => {
const { Wrapper } = createWrapper();
mockGetActivity.mockResolvedValueOnce([{ id: '1' }]);
const { result, waitForNextUpdate } = renderHook(() =>
usePredictActivity(),
);

await waitForNextUpdate();
const { result } = renderHook(() => usePredictActivity(), {
wrapper: Wrapper,
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

mockGetActivity.mockResolvedValueOnce([{ id: '2' }]);
await act(async () => {
await result.current.loadActivity({ isRefresh: true });
await result.current.refetch();
});

expect(result.current.isRefreshing).toBe(false);
expect(result.current.activity).toEqual([{ id: '2' }]);
});

it('handles errors and sets error message', async () => {
mockGetActivity.mockRejectedValueOnce(new Error('Boom'));

const { result, waitForNextUpdate } = renderHook(() =>
usePredictActivity(),
);

await waitForNextUpdate();

expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe('Boom');
expect(result.current.activity).toEqual([]);
});

it('supports disabling auto-load via loadOnMount=false', () => {
const { result } = renderHook(() =>
usePredictActivity({ loadOnMount: false }),
);

expect(result.current.isLoading).toBe(true);
expect(result.current.activity).toEqual([]);
expect(mockGetActivity).not.toHaveBeenCalled();
expect(mockGetActivity).toHaveBeenCalledTimes(2);
expect(result.current.isRefetching).toBe(false);
});

it('triggers refresh on focus when refreshOnFocus=true', async () => {
mockGetActivity.mockResolvedValueOnce([]);

const { waitForNextUpdate } = renderHook(() =>
usePredictActivity({ refreshOnFocus: true }),
);

await waitForNextUpdate();
it('ensures polygon network before running query', async () => {
const { Wrapper } = createWrapper();
const { result } = renderHook(() => usePredictActivity(), {
wrapper: Wrapper,
});

mockGetActivity.mockResolvedValueOnce([]);
const { __mock } = jest.requireMock('@react-navigation/native');
await act(async () => {
__mock.invokeFocusEffect();
await Promise.resolve();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(mockGetActivity).toHaveBeenCalledTimes(2);
expect(mockEnsurePolygonNetworkExists).toHaveBeenCalledTimes(1);
});
});
110 changes: 32 additions & 78 deletions app/components/UI/Predict/hooks/usePredictActivity.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,43 @@
import { useFocusEffect } from '@react-navigation/native';
import { useCallback, useEffect, useState } from 'react';
import Engine from '../../../../core/Engine';
import { useEffect } from 'react';
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import Logger from '../../../../util/Logger';
import { PREDICT_CONSTANTS } from '../constants/errors';
import type { PredictActivity } from '../types';
import { usePredictNetworkManagement } from './usePredictNetworkManagement';
import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts';
import { predictQueries } from '../queries';
import { ensureError } from '../utils/predictErrorHandler';

interface UsePredictActivityOptions {
loadOnMount?: boolean;
refreshOnFocus?: boolean;
}

interface UsePredictActivityReturn {
activity: PredictActivity[];
isLoading: boolean;
isRefreshing: boolean;
error: string | null;
loadActivity: (options?: { isRefresh?: boolean }) => Promise<void>;
}

export function usePredictActivity(
options: UsePredictActivityOptions = {},
): UsePredictActivityReturn {
const { loadOnMount = true, refreshOnFocus = true } = options;
export function usePredictActivity(): UseQueryResult<PredictActivity[], Error> {
const { ensurePolygonNetworkExists } = usePredictNetworkManagement();

const [activity, setActivity] = useState<PredictActivity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);

const loadActivity = useCallback(
async (loadOptions?: { isRefresh?: boolean }) => {
const { isRefresh = false } = loadOptions || {};
try {
if (isRefresh) {
setIsRefreshing(true);
} else {
setIsLoading(true);
}
setError(null);

const controller = Engine.context.PredictController;
const data = await controller.getActivity({});
setActivity(data ?? []);
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to load activity';
setError(message);

// Capture exception with activity loading context (no user address)
Logger.error(ensureError(err), {
tags: {
feature: PREDICT_CONSTANTS.FEATURE_NAME,
component: 'usePredictActivity',
},
context: {
name: 'usePredictActivity',
data: {
method: 'loadActivity',
action: 'activity_load',
operation: 'data_fetching',
},
},
});
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
},
[],
);
const evmAccount = getEvmAccountFromSelectedAccountGroup();
const address = evmAccount?.address ?? '0x0';

useEffect(() => {
if (loadOnMount) {
loadActivity();
}
}, [loadOnMount, loadActivity]);
ensurePolygonNetworkExists().catch(() => undefined);
}, [ensurePolygonNetworkExists]);

useFocusEffect(
useCallback(() => {
if (refreshOnFocus) {
loadActivity({ isRefresh: true });
}
}, [refreshOnFocus, loadActivity]),
);
const queryResult = useQuery(predictQueries.activity.options({ address }));

return { activity, isLoading, isRefreshing, error, loadActivity };
useEffect(() => {
if (!queryResult.error) return;

Logger.error(ensureError(queryResult.error), {
tags: {
feature: PREDICT_CONSTANTS.FEATURE_NAME,
component: 'usePredictActivity',
},
context: {
name: 'usePredictActivity',
data: {
method: 'queryFn',
action: 'activity_load',
operation: 'data_fetching',
},
},
});
}, [queryResult.error]);

return queryResult;
}
4 changes: 4 additions & 0 deletions app/components/UI/Predict/hooks/usePredictPlaceOrder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ export function usePredictPlaceOrder(
queryKey: predictQueries.positions.keys.all(),
});

queryClient.invalidateQueries({
queryKey: predictQueries.activity.keys.all(),
});

if (side === Side.BUY) {
showOrderPlacedToast();
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => {
queryClient.invalidateQueries({
queryKey: predictQueries.balance.keys.all(),
});

queryClient.invalidateQueries({
queryKey: predictQueries.activity.keys.all(),
});
}

if (type === 'deposit') {
Expand Down
Loading
Loading