From ec375e0a1f5416fc68197d9a46b9a8be244f7776 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Wed, 4 Feb 2026 14:22:46 -0700 Subject: [PATCH 1/5] Add memory reporter --- .github/workflows/test.yml | 34 +++++++++++++++++- tests/utils/MemoryReporter.js | 44 +++++++++++++++++++++++ tests/utils/summarizeMemory.js | 64 ++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/utils/MemoryReporter.js create mode 100644 tests/utils/summarizeMemory.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0bc5aedb6814e..a1df3bb3df19a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,14 @@ 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 }} + 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 --reporterOptions=output=jest-memory-${{ matrix.chunk }}.json + + - name: Upload memory artifact + if: ${{ always() }} + uses: actions/upload-artifact@c7d193f32edb3c5b26950aacbe4aaf96e3c6a1e9 # v4.3.0 + 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' }} @@ -81,6 +88,31 @@ 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 + uses: actions/download-artifact@3c8623c72862da8d75e2084bb0466a00d2008dcd # v4.1.4 + 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 diff --git a/tests/utils/MemoryReporter.js b/tests/utils/MemoryReporter.js new file mode 100644 index 0000000000000..dfd6b416be039 --- /dev/null +++ b/tests/utils/MemoryReporter.js @@ -0,0 +1,44 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Simple Jest reporter that records RSS memory after each test file. + * Produces an array of {path, rssMB, deltaMB} entries. + * Use with --reporters=./tests/utils/MemoryReporter.js and optionally pass + * --reporterOptions output=jest-memory.json to change the output filename. + */ +class MemoryReporter { + constructor(globalConfig, options) { + this.results = []; + const requestedOutput = options?.output; + // Default name matches Jest shard so we can disambiguate artifacts. + this.output = requestedOutput || 'jest-memory.json'; + } + + onTestStart(test) { + // Capture baseline RSS at file start. + test._memStart = process.memoryUsage().rss; + } + + onTestResult(test, testResult) { + const rssEnd = process.memoryUsage().rss; + const rssMB = Math.round(rssEnd / 1024 / 1024); + const deltaMB = Math.round((rssEnd - (test._memStart ?? rssEnd)) / 1024 / 1024); + this.results.push({ + path: testResult.testFilePath, + rssMB, + deltaMB, + }); + } + + onRunComplete() { + const outputPath = path.resolve(process.cwd(), this.output); + fs.writeFileSync(outputPath, JSON.stringify(this.results, null, 2)); + // Also print a concise line so it appears in raw logs for quick inspection. + const maxRss = this.results.reduce((max, r) => Math.max(max, r.rssMB), 0); + // eslint-disable-next-line no-console + console.log(`[memory-reporter] wrote ${this.results.length} entries to ${this.output}; max RSS ${maxRss} MB`); + } +} + +module.exports = MemoryReporter; diff --git a/tests/utils/summarizeMemory.js b/tests/utils/summarizeMemory.js new file mode 100644 index 0000000000000..ffc582e33d7ad --- /dev/null +++ b/tests/utils/summarizeMemory.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Combine per-shard jest-memory*.json files and emit a Markdown summary. + * Usage: node tests/utils/summarizeMemory.js + */ +const fs = require('fs'); +const path = require('path'); + +const artifactsDir = process.argv[2] || '.'; +const files = fs.readdirSync(artifactsDir).filter((f) => f.startsWith('jest-memory-') && f.endsWith('.json')); + +if (!files.length) { + console.log('No jest-memory-*.json artifacts found.'); + process.exit(0); +} + +const allEntries = []; + +files.forEach((file) => { + const shard = file.replace(/^jest-memory-/, '').replace(/\.json$/, ''); + const data = JSON.parse(fs.readFileSync(path.join(artifactsDir, file), 'utf8')); + data.forEach((entry) => allEntries.push({...entry, shard})); +}); + +const maxRss = Math.max(...allEntries.map((e) => e.rssMB)); +const avgRss = Math.round(allEntries.reduce((sum, e) => sum + e.rssMB, 0) / allEntries.length); + +const byShard = files.map((file) => { + const shard = file.replace(/^jest-memory-/, '').replace(/\.json$/, ''); + const data = JSON.parse(fs.readFileSync(path.join(artifactsDir, file), 'utf8')); + const shardMax = Math.max(...data.map((e) => e.rssMB)); + return {shard, max: shardMax}; +}); + +const top = [...allEntries].sort((a, b) => b.rssMB - a.rssMB).slice(0, 10); + +const summaryLines = []; +summaryLines.push(`Jest memory summary`); +summaryLines.push(`Files: ${allEntries.length}`); +summaryLines.push(`Shards: ${files.length}`); +summaryLines.push(`Overall max RSS: ${maxRss} MB`); +summaryLines.push(`Average RSS: ${avgRss} MB`); +summaryLines.push(''); +summaryLines.push(`Top ${top.length} suites by RSS:`); +summaryLines.push(`| Suite | Shard | RSS (MB) | Delta (MB) |`); +summaryLines.push(`| --- | --- | --- | --- |`); +top.forEach((t) => { + summaryLines.push(`| ${path.relative(process.cwd(), t.path)} | ${t.shard} | ${t.rssMB} | ${t.deltaMB} |`); +}); +summaryLines.push(''); +summaryLines.push(`Max RSS per shard:`); +summaryLines.push(`| Shard | Max RSS (MB) |`); +summaryLines.push(`| --- | --- |`); +byShard.forEach(({shard, max}) => summaryLines.push(`| ${shard} | ${max} |`)); + +const summary = summaryLines.join('\n'); + +// Write to summary for GitHub Actions. +if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); +} + +// Also print to stdout for logs or local use. +console.log(summary); From 1a7acdc889f6d5d5f09cb46985a2e2d747d200b1 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Wed, 4 Feb 2026 14:25:44 -0700 Subject: [PATCH 2/5] Use tag --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1df3bb3df19a..e0384b2a23a4d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,7 +48,7 @@ jobs: - name: Upload memory artifact if: ${{ always() }} - uses: actions/upload-artifact@c7d193f32edb3c5b26950aacbe4aaf96e3c6a1e9 # v4.3.0 + uses: actions/upload-artifact@v4 with: name: jest-memory-${{ matrix.chunk }} path: jest-memory-${{ matrix.chunk }}.json @@ -101,7 +101,7 @@ jobs: uses: ./.github/actions/composite/setupNode - name: Download memory artifacts - uses: actions/download-artifact@3c8623c72862da8d75e2084bb0466a00d2008dcd # v4.1.4 + uses: actions/download-artifact@v4 continue-on-error: true with: pattern: jest-memory-* From 1f38b80968a848c3b1b0726f3bde4b8e8c4424d3 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Wed, 4 Feb 2026 14:44:39 -0700 Subject: [PATCH 3/5] Fix reporter --- .github/workflows/test.yml | 10 +++++++--- tests/utils/MemoryReporter.js | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0384b2a23a4d..a1fda3f3cc7a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,11 +44,14 @@ 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 }} --reporters=default --reporters=./tests/utils/MemoryReporter.js --reporterOptions=output=jest-memory-${{ matrix.chunk }}.json + 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() }} - uses: actions/upload-artifact@v4 + # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: jest-memory-${{ matrix.chunk }} path: jest-memory-${{ matrix.chunk }}.json @@ -101,7 +104,8 @@ jobs: uses: ./.github/actions/composite/setupNode - name: Download memory artifacts - uses: actions/download-artifact@v4 + # v4.2.1 + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e continue-on-error: true with: pattern: jest-memory-* diff --git a/tests/utils/MemoryReporter.js b/tests/utils/MemoryReporter.js index dfd6b416be039..c45aed66d929e 100644 --- a/tests/utils/MemoryReporter.js +++ b/tests/utils/MemoryReporter.js @@ -11,8 +11,9 @@ class MemoryReporter { constructor(globalConfig, options) { this.results = []; const requestedOutput = options?.output; + const envOutput = process.env.JEST_MEMORY_OUTPUT || process.env.MEMORY_REPORTER_OUTPUT; // Default name matches Jest shard so we can disambiguate artifacts. - this.output = requestedOutput || 'jest-memory.json'; + this.output = requestedOutput || envOutput || 'jest-memory.json'; } onTestStart(test) { From a7e353b31a17073e023948354c780dd8cb49b4c4 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Wed, 4 Feb 2026 15:17:20 -0700 Subject: [PATCH 4/5] Apply some memory fixes --- jest/setup.ts | 44 +++++++++++++++------ jest/setupAfterEnv.ts | 7 ++++ src/libs/actions/OnyxUpdateManager/index.ts | 40 ++++++++++++++++--- src/libs/actions/__mocks__/OnyxUpdates.ts | 33 +++++++++++++--- tests/actions/OnyxUpdateManagerTest.ts | 18 ++++++++- tests/unit/OnyxUpdateManagerTest.ts | 15 ++++++- 6 files changed, 133 insertions(+), 24 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index bab939a5d8f4c..0934d659dcea7 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -105,7 +105,16 @@ jest.mock('react-native-reanimated', () => ({ })); // 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', + () => ({ + scheduleOnUI: jest.fn((worklet: (...args: unknown[]) => unknown, ...args: unknown[]) => worklet?.(...args)), + scheduleOnRN: jest.fn((worklet: (...args: unknown[]) => unknown, ...args: unknown[]) => worklet?.(...args)), + runOnUISync: jest.fn((worklet: (...args: unknown[]) => unknown, ...args: unknown[]) => worklet?.(...args)), + }), + // Treat as virtual; the native package isn't available in the Jest runtime. + {virtual: true}, +); jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')); @@ -280,18 +289,29 @@ jest.mock('react-native-nitro-sqlite', () => ({ 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) => 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) => 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 diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts index 3ce0fa37247eb..9e80a689ea380 100644 --- a/jest/setupAfterEnv.ts +++ b/jest/setupAfterEnv.ts @@ -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(); +}); diff --git a/src/libs/actions/OnyxUpdateManager/index.ts b/src/libs/actions/OnyxUpdateManager/index.ts index 3e485a5bda3b3..bc22eabb60ba4 100644 --- a/src/libs/actions/OnyxUpdateManager/index.ts +++ b/src/libs/actions/OnyxUpdateManager/index.ts @@ -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 | undefined; +let isLoadingApp = false; +let isLoadingConnection: ReturnType | undefined; +let onyxUpdatesFromServerConnection: ReturnType | 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; @@ -231,10 +235,14 @@ function updateAuthTokenIfNecessary(onyxUpdatesFromServer: OnyxEntry { + 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); @@ -242,4 +250,26 @@ export default () => { }); }; -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}; diff --git a/src/libs/actions/__mocks__/OnyxUpdates.ts b/src/libs/actions/__mocks__/OnyxUpdates.ts index e54b423259f13..aed3dd573406c 100644 --- a/src/libs/actions/__mocks__/OnyxUpdates.ts +++ b/src/libs/actions/__mocks__/OnyxUpdates.ts @@ -10,16 +10,29 @@ const {doesClientNeedToBeUpdated, saveUpdateInformation, INTERNAL_DO_NOT_USE_app type OnyxUpdatesMock = typeof OnyxUpdatesImport & { apply: jest.Mock, [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 | 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 | undefined => { + ensureConnection(); + if (lastUpdateID && (lastUpdateIDAppliedToClient === undefined || Number(lastUpdateID) > lastUpdateIDAppliedToClient)) { Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, Number(lastUpdateID)); } @@ -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, diff --git a/tests/actions/OnyxUpdateManagerTest.ts b/tests/actions/OnyxUpdateManagerTest.ts index 73ef3b799b84b..6c4dd4c2f01f2 100644 --- a/tests/actions/OnyxUpdateManagerTest.ts +++ b/tests/actions/OnyxUpdateManagerTest.ts @@ -5,6 +5,8 @@ import type {AppActionsMock} from '@libs/actions/__mocks__/App'; // 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'; +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'; @@ -45,6 +47,7 @@ jest.mock('@src/libs/SearchUIUtils', () => ({ 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'; @@ -130,14 +133,27 @@ OnyxUpdateManager(); describe('actions/OnyxUpdateManager', () => { let reportActions: OnyxEntry; + let reportActionsConnection: ReturnType | 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(); diff --git a/tests/unit/OnyxUpdateManagerTest.ts b/tests/unit/OnyxUpdateManagerTest.ts index a5b620b62ce70..f802eed4b0bf4 100644 --- a/tests/unit/OnyxUpdateManagerTest.ts +++ b/tests/unit/OnyxUpdateManagerTest.ts @@ -51,13 +51,26 @@ const update8 = OnyxUpdateMockUtils.createUpdate(8); describe('OnyxUpdateManager', () => { let lastUpdateIDAppliedToClient = 1; + let lastUpdateConnection: ReturnType | 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); From a5f64e2431d6fbf7aa88aa25e9de4e807fd4ceb1 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Wed, 4 Feb 2026 15:49:41 -0700 Subject: [PATCH 5/5] Investigate Jest memory issues --- .github/workflows/test.yml | 2 +- jest/setup.ts | 55 +++++++++++++++++++++++++++++++---- tests/utils/MemoryReporter.js | 15 ++++++++-- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1fda3f3cc7a5..ace0d0844edbe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,7 +49,7 @@ jobs: 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() }} + if: ${{ always() && hashFiles(format('jest-memory-{0}.json', matrix.chunk)) != '' }} # v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: diff --git a/jest/setup.ts b/jest/setup.ts index 0934d659dcea7..65047f842f28a 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -107,11 +107,56 @@ jest.mock('react-native-reanimated', () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return jest.mock( 'react-native-worklets', - () => ({ - scheduleOnUI: jest.fn((worklet: (...args: unknown[]) => unknown, ...args: unknown[]) => worklet?.(...args)), - scheduleOnRN: jest.fn((worklet: (...args: unknown[]) => unknown, ...args: unknown[]) => worklet?.(...args)), - runOnUISync: jest.fn((worklet: (...args: unknown[]) => unknown, ...args: unknown[]) => worklet?.(...args)), - }), + () => { + // Keep this mock lightweight but compatible with `react-native-reanimated/mock`, + // which imports several Worklets helpers (e.g. createSerializable). + const serializableCache = new WeakMap(); + const serializableMappingCache = { + set: (value: object, serializableRef?: unknown) => serializableCache.set(value, serializableRef ?? value), + get: (value: object) => serializableCache.get(value), + }; + + // 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 = + (worklet: (...args: Args) => ReturnValue) => + (...args: Args): ReturnValue => + worklet?.(...args); + + const schedule = + (worklet: (...args: Args) => ReturnValue, ...args: Args): ReturnValue => + worklet?.(...args); + + return { + // Serialization helpers used by Reanimated at module init time + createSerializable: jest.fn((value: T) => value), + serializableMappingCache, + + // Runtime helpers + RuntimeKind, + getRuntimeKind: jest.fn(() => globalThis.__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((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?.()), + }; + }, // Treat as virtual; the native package isn't available in the Jest runtime. {virtual: true}, ); diff --git a/tests/utils/MemoryReporter.js b/tests/utils/MemoryReporter.js index c45aed66d929e..209d2d53aed81 100644 --- a/tests/utils/MemoryReporter.js +++ b/tests/utils/MemoryReporter.js @@ -14,6 +14,15 @@ class MemoryReporter { const envOutput = process.env.JEST_MEMORY_OUTPUT || process.env.MEMORY_REPORTER_OUTPUT; // Default name matches Jest shard so we can disambiguate artifacts. this.output = requestedOutput || envOutput || 'jest-memory.json'; + this.outputPath = path.resolve(process.cwd(), this.output); + + // Ensure the output file exists even if Jest aborts early (OOM, crash, etc.). + fs.mkdirSync(path.dirname(this.outputPath), {recursive: true}); + this.flush(); + } + + flush() { + fs.writeFileSync(this.outputPath, JSON.stringify(this.results, null, 2)); } onTestStart(test) { @@ -30,11 +39,13 @@ class MemoryReporter { rssMB, deltaMB, }); + + // Persist incrementally so we still get partial data if the run crashes later. + this.flush(); } onRunComplete() { - const outputPath = path.resolve(process.cwd(), this.output); - fs.writeFileSync(outputPath, JSON.stringify(this.results, null, 2)); + this.flush(); // Also print a concise line so it appears in raw logs for quick inspection. const maxRss = this.results.reduce((max, r) => Math.max(max, r.rssMB), 0); // eslint-disable-next-line no-console