Skip to content
Draft
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
38 changes: 37 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,17 @@ jobs:
key: ${{ runner.os }}-jest

- name: Jest tests
run: npm test -- --silent --shard=${{ fromJSON(matrix.chunk) }}/${{ strategy.job-total }} --maxWorkers=2 --coverage --coverageDirectory=coverage/shard-${{ matrix.chunk }}
env:
JEST_MEMORY_OUTPUT: jest-memory-${{ matrix.chunk }}.json
run: npm test -- --silent --shard=${{ fromJSON(matrix.chunk) }}/${{ strategy.job-total }} --maxWorkers=2 --coverage --coverageDirectory=coverage/shard-${{ matrix.chunk }} --reporters=default --reporters=./tests/utils/MemoryReporter.js

- name: Upload memory artifact
if: ${{ always() && hashFiles(format('jest-memory-{0}.json', matrix.chunk)) != '' }}
# v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: jest-memory-${{ matrix.chunk }}
path: jest-memory-${{ matrix.chunk }}.json

- name: Upload coverage to Codecov (PRs - tokenless)
if: ${{ github.event_name == 'pull_request' }}
Expand Down Expand Up @@ -81,6 +91,32 @@ jobs:
disable_search: true
fail_ci_if_error: true

jest-memory-summary:
name: Jest memory summary
needs: jest
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608

- name: Setup Node
uses: ./.github/actions/composite/setupNode

- name: Download memory artifacts
# v4.2.1
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e
continue-on-error: true
with:
pattern: jest-memory-*
path: memory-artifacts
merge-multiple: true

- name: Summarize memory usage
run: |
mkdir -p memory-artifacts
node tests/utils/summarizeMemory.js memory-artifacts

storybookTests:
if: ${{ github.event.head_commit.author.name != 'OSBotify' && github.event.head_commit.author.name != 'imgbot[bot]' || github.event_name == 'workflow_call' }}
runs-on: ubuntu-latest
Expand Down
89 changes: 77 additions & 12 deletions jest/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,61 @@
}));

// eslint-disable-next-line @typescript-eslint/no-unsafe-return
jest.mock('react-native-worklets', () => require('react-native-worklets/src/mock'));
jest.mock(
'react-native-worklets',
() => {
// Keep this mock lightweight but compatible with `react-native-reanimated/mock`,
// which imports several Worklets helpers (e.g. createSerializable).
const serializableCache = new WeakMap<object, unknown>();

Check failure on line 113 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Don't use `object` as a type. Use 'Record<string, T>' instead

Check failure on line 113 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Don't use `object` as a type. Use 'Record<string, T>' instead

Check failure on line 113 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Don't use `object` as a type. Use 'Record<string, T>' instead
const serializableMappingCache = {
set: (value: object, serializableRef?: unknown) => serializableCache.set(value, serializableRef ?? value),

Check failure on line 115 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Don't use `object` as a type. Use 'Record<string, T>' instead

Check failure on line 115 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Don't use `object` as a type. Use 'Record<string, T>' instead

Check failure on line 115 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Don't use `object` as a type. Use 'Record<string, T>' instead
get: (value: object) => serializableCache.get(value),

Check failure on line 116 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Don't use `object` as a type. Use 'Record<string, T>' instead

Check failure on line 116 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Don't use `object` as a type. Use 'Record<string, T>' instead

Check failure on line 116 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Don't use `object` as a type. Use 'Record<string, T>' instead
};

// Mirror enum values from `react-native-worklets` (ReactNative=1, UI=2, Worker=3).
const RuntimeKind = {ReactNative: 1, UI: 2, Worker: 3};
// Ensure it's defined for code paths that read it.
// eslint-disable-next-line no-underscore-dangle
globalThis.__RUNTIME_KIND = globalThis.__RUNTIME_KIND ?? RuntimeKind.ReactNative;

const runDirect =
<Args extends unknown[], ReturnValue>(worklet: (...args: Args) => ReturnValue) =>
(...args: Args): ReturnValue =>
worklet?.(...args);

const schedule =
<Args extends unknown[], ReturnValue>(worklet: (...args: Args) => ReturnValue, ...args: Args): ReturnValue =>
worklet?.(...args);

return {
// Serialization helpers used by Reanimated at module init time
createSerializable: jest.fn(<T>(value: T) => value),
serializableMappingCache,

// Runtime helpers
RuntimeKind,
getRuntimeKind: jest.fn(() => globalThis.__RUNTIME_KIND),

Check failure on line 141 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Unexpected dangling '_' in '__RUNTIME_KIND'

Check failure on line 141 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Unexpected dangling '_' in '__RUNTIME_KIND'

Check failure on line 141 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Unexpected dangling '_' in '__RUNTIME_KIND'

// Worklet helpers
isWorkletFunction: jest.fn(() => true),

// Threading helpers (execute synchronously in Jest)
runOnUI: jest.fn(runDirect),
runOnUIAsync: jest.fn(runDirect),
runOnJS: jest.fn(runDirect),
executeOnUIRuntimeSync: jest.fn(runDirect),
scheduleOnUI: jest.fn(schedule),
scheduleOnRN: jest.fn(schedule),
runOnUISync: jest.fn(<Args extends unknown[], ReturnValue>(worklet: (...args: Args) => ReturnValue, ...args: Args) => worklet?.(...args)),

// Misc exports that might be imported by Reanimated, no-op in Jest
callMicrotasks: jest.fn(),
unstable_eventLoopTask: jest.fn((task: () => unknown) => task?.()),

Check failure on line 157 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Object Literal Property name `unstable_eventLoopTask` must match one of the following formats: camelCase, UPPER_CASE, PascalCase

Check failure on line 157 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Object Literal Property name `unstable_eventLoopTask` must match one of the following formats: camelCase, UPPER_CASE, PascalCase
};
},
// Treat as virtual; the native package isn't available in the Jest runtime.
{virtual: true},
);

jest.mock('react-native-keyboard-controller', () => require<typeof RNKeyboardController>('react-native-keyboard-controller/jest'));

Expand Down Expand Up @@ -280,18 +334,29 @@
open: jest.fn(),
}));

jest.mock('@shopify/react-native-skia', () => ({
useFont: jest.fn(() => null),
matchFont: jest.fn(() => null),
listFontFamilies: jest.fn(() => []),
}));
jest.mock(
'@shopify/react-native-skia',
() => ({
useFont: jest.fn(() => null),
matchFont: jest.fn(() => null),
listFontFamilies: jest.fn(() => []),
}),
// The real package isn't installed in the Jest environment; mock it virtually so resolution succeeds.
{virtual: true},
);

jest.mock('victory-native', () => ({
Bar: jest.fn(() => null),
CartesianChart: jest.fn(
({children}: {children?: (args: Record<string, unknown>) => ReactNode}) => children?.({points: {y: []}, chartBounds: {left: 0, right: 0, top: 0, bottom: 0}}) ?? null,
),
}));
jest.mock(
'victory-native',
() => ({
Bar: jest.fn(() => null),
CartesianChart: jest.fn(
({children}: {children?: (args: Record<string, unknown>) => ReactNode}) =>
children?.({points: {y: []}, chartBounds: {left: 0, right: 0, top: 0, bottom: 0}}) ?? null,
),
}),
// Mock virtually so Jest doesn't need the native victory package installed locally.
{virtual: true},
);

// Provide a default global fetch mock for tests that do not explicitly set it up
// This avoids ReferenceError: fetch is not defined in CI when coverage is enabled
Expand Down
7 changes: 7 additions & 0 deletions jest/setupAfterEnv.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import OnyxConnectionManager from 'react-native-onyx/dist/OnyxConnectionManager';

jest.useRealTimers();

afterAll(() => {
OnyxConnectionManager.disconnectAll();
return Onyx.clear();
});
40 changes: 35 additions & 5 deletions src/libs/actions/OnyxUpdateManager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,21 @@ import {
// Therefore, SaveResponseInOnyx.js can't import and use this file directly.

let lastUpdateIDAppliedToClient: number = CONST.DEFAULT_NUMBER_ID;
let lastUpdateConnection: ReturnType<typeof Onyx.connectWithoutView> | undefined;
let isLoadingApp = false;
let isLoadingConnection: ReturnType<typeof Onyx.connectWithoutView> | undefined;
let onyxUpdatesFromServerConnection: ReturnType<typeof Onyx.connectWithoutView> | undefined;

// `lastUpdateIDAppliedToClient` is not dependent on any changes on the UI,
// so it is okay to use `connectWithoutView` here.
Onyx.connectWithoutView({
lastUpdateConnection = Onyx.connectWithoutView({
key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
callback: (value) => (lastUpdateIDAppliedToClient = value ?? CONST.DEFAULT_NUMBER_ID),
});

let isLoadingApp = false;
// `isLoadingApp` is not dependent on any changes on the UI,
// so it is okay to use `connectWithoutView` here.
Onyx.connectWithoutView({
isLoadingConnection = Onyx.connectWithoutView({
key: ONYXKEYS.IS_LOADING_APP,
callback: (value) => {
isLoadingApp = value ?? false;
Expand Down Expand Up @@ -231,15 +235,41 @@ function updateAuthTokenIfNecessary(onyxUpdatesFromServer: OnyxEntry<OnyxUpdates
}

export default () => {
if (onyxUpdatesFromServerConnection) {
return;
}

console.debug('[OnyxUpdateManager] Listening for updates from the server');
// `Onyx updates` are not dependent on any changes on the UI,
// so it is okay to use `connectWithoutView` here.
Onyx.connectWithoutView({
onyxUpdatesFromServerConnection = Onyx.connectWithoutView({
key: ONYXKEYS.ONYX_UPDATES_FROM_SERVER,
callback: (value) => {
handleMissingOnyxUpdates(value);
},
});
};

export {handleMissingOnyxUpdates, queryPromiseWrapper as queryPromise, resetDeferralLogicVariables};
const disconnectForTesting = () => {
if (onyxUpdatesFromServerConnection) {
Onyx.disconnect(onyxUpdatesFromServerConnection);
onyxUpdatesFromServerConnection = undefined;
}

if (lastUpdateConnection) {
Onyx.disconnect(lastUpdateConnection);
lastUpdateConnection = undefined;
}

if (isLoadingConnection) {
Onyx.disconnect(isLoadingConnection);
isLoadingConnection = undefined;
}

clearDeferredOnyxUpdates({shouldResetGetMissingOnyxUpdatesPromise: true, shouldUnpauseSequentialQueue: false});
resolveQueryPromiseWrapper?.();
queryPromiseWrapper = createQueryPromiseWrapper();
isFetchingForPendingUpdates = false;
};

export {handleMissingOnyxUpdates, queryPromiseWrapper as queryPromise, resetDeferralLogicVariables, disconnectForTesting};
33 changes: 28 additions & 5 deletions src/libs/actions/__mocks__/OnyxUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,29 @@ const {doesClientNeedToBeUpdated, saveUpdateInformation, INTERNAL_DO_NOT_USE_app

type OnyxUpdatesMock = typeof OnyxUpdatesImport & {
apply: jest.Mock<Promise<Response | void>, [OnyxUpdatesFromServer]>;
resetMock: () => void;
};

let lastUpdateIDAppliedToClient: number | undefined = 0;
// Use connectWithoutView because this is a mock for testing and does not involve any UI updates.
Onyx.connectWithoutView({
key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
callback: (val) => (lastUpdateIDAppliedToClient = val),
});
let lastUpdateConnection: ReturnType<typeof Onyx.connectWithoutView> | undefined;

const ensureConnection = () => {
if (lastUpdateConnection) {
return;
}

// Use connectWithoutView because this is a mock for testing and does not involve any UI updates.
lastUpdateConnection = Onyx.connectWithoutView({
key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
callback: (val) => (lastUpdateIDAppliedToClient = val),
});
};

ensureConnection();

const apply = jest.fn(({lastUpdateID, request, response}: OnyxUpdatesFromServer): Promise<void | Response> | undefined => {
ensureConnection();

if (lastUpdateID && (lastUpdateIDAppliedToClient === undefined || Number(lastUpdateID) > lastUpdateIDAppliedToClient)) {
Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, Number(lastUpdateID));
}
Expand All @@ -31,9 +44,19 @@ const apply = jest.fn(({lastUpdateID, request, response}: OnyxUpdatesFromServer)
return Promise.resolve();
});

const resetMock = () => {
lastUpdateIDAppliedToClient = 0;

if (lastUpdateConnection) {
Onyx.disconnect(lastUpdateConnection);
lastUpdateConnection = undefined;
}
};

export {
// Mocks
apply,
resetMock,

// Actual OnyxUpdates implementation
doesClientNeedToBeUpdated,
Expand Down
18 changes: 17 additions & 1 deletion tests/actions/OnyxUpdateManagerTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// eslint-disable-next-line no-restricted-syntax -- this is required to allow mocking
import * as AppImport from '@libs/actions/App';
import applyOnyxUpdatesReliably from '@libs/actions/applyOnyxUpdatesReliably';
import * as OnyxUpdates from '@userActions/OnyxUpdates';

Check failure on line 8 in tests/actions/OnyxUpdateManagerTest.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Namespace imports from @userActions are not allowed. Use named imports instead. Example: import { action } from "@userActions/module"
import type {OnyxUpdatesMock} from '@userActions/__mocks__/OnyxUpdates';
// eslint-disable-next-line no-restricted-syntax -- this is required to allow mocking
import * as OnyxUpdateManagerExports from '@libs/actions/OnyxUpdateManager';
import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types';
Expand Down Expand Up @@ -45,6 +47,7 @@
const App = AppImport as AppActionsMock;
const ApplyUpdates = ApplyUpdatesImport as ApplyUpdatesMock;
const OnyxUpdateManagerUtils = OnyxUpdateManagerUtilsImport as OnyxUpdateManagerUtilsMock;
const OnyxUpdatesMocked = OnyxUpdates as OnyxUpdatesMock;

const TEST_USER_ACCOUNT_ID = 1;
const REPORT_ID = 'testReport1';
Expand Down Expand Up @@ -130,14 +133,27 @@

describe('actions/OnyxUpdateManager', () => {
let reportActions: OnyxEntry<OnyxTypes.ReportActions>;
let reportActionsConnection: ReturnType<typeof Onyx.connect> | undefined;

beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
Onyx.connect({
reportActionsConnection = Onyx.connect({
key: ONYX_KEY,
callback: (val) => (reportActions = val),
});
});

afterAll(() => {
if (reportActionsConnection) {
Onyx.disconnect(reportActionsConnection);
}

OnyxUpdatesMocked.resetMock();
OnyxUpdateManagerExports.disconnectForTesting();
jest.resetModules();
return Onyx.clear();
});

beforeEach(async () => {
jest.clearAllMocks();
await Onyx.clear();
Expand Down
15 changes: 14 additions & 1 deletion tests/unit/OnyxUpdateManagerTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,26 @@ const update8 = OnyxUpdateMockUtils.createUpdate(8);

describe('OnyxUpdateManager', () => {
let lastUpdateIDAppliedToClient = 1;
let lastUpdateConnection: ReturnType<typeof Onyx.connect> | undefined;

beforeAll(() => {
Onyx.connect({
lastUpdateConnection = Onyx.connect({
key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
callback: (value) => (lastUpdateIDAppliedToClient = value ?? 1),
});
});

afterAll(() => {
if (lastUpdateConnection) {
Onyx.disconnect(lastUpdateConnection);
}

OnyxUpdates.resetMock();
OnyxUpdateManager.disconnectForTesting();
jest.resetModules();
return Onyx.clear();
});

beforeEach(async () => {
jest.clearAllMocks();
await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 1);
Expand Down
Loading
Loading