From 2f8642eb340e2483eef95bdea374878f47f6c0c6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 2 Jun 2026 20:46:17 +0200 Subject: [PATCH 01/11] feat(runtime): track pending promises --- .../src/__tests__/promise-tracker.test.ts | 104 +++++++++++++ .../src/__tests__/runner-context.test.ts | 138 +++++++++++------ packages/runtime/src/client/factory.ts | 12 +- packages/runtime/src/index.ts | 1 + packages/runtime/src/promise-tracker.ts | 139 ++++++++++++++++++ packages/runtime/src/runner/runSuite.ts | 68 +++++---- 6 files changed, 391 insertions(+), 71 deletions(-) create mode 100644 packages/runtime/src/__tests__/promise-tracker.test.ts create mode 100644 packages/runtime/src/promise-tracker.ts diff --git a/packages/runtime/src/__tests__/promise-tracker.test.ts b/packages/runtime/src/__tests__/promise-tracker.test.ts new file mode 100644 index 00000000..9773f69e --- /dev/null +++ b/packages/runtime/src/__tests__/promise-tracker.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + clearTrackedPromises, + getPendingPromises, + installPromiseTracker, + uninstallPromiseTracker, + withPromiseTrackerTestContext, +} from '../promise-tracker.js'; + +afterEach(() => { + uninstallPromiseTracker(); +}); + +describe('promise tracker', () => { + it('tracks pending promises created through the global Promise constructor', () => { + installPromiseTracker(); + + void new Promise(() => undefined); + + const pending = getPendingPromises(); + expect(pending).toHaveLength(1); + expect(pending[0]).toMatchObject({ + id: expect.any(Number), + createdAt: expect.any(Number), + }); + expect(pending[0].stack).toContain('Promise created'); + }); + + it('removes promises when they resolve', async () => { + installPromiseTracker(); + + await Promise.resolve('done'); + + expect(getPendingPromises()).toHaveLength(0); + }); + + it('removes promises when they reject', async () => { + installPromiseTracker(); + + await Promise.reject(new Error('failed')).catch(() => undefined); + + expect(getPendingPromises()).toHaveLength(0); + }); + + it('removes promises when the executor throws', async () => { + installPromiseTracker(); + + await new Promise(() => { + throw new Error('executor failed'); + }).catch(() => undefined); + + expect(getPendingPromises()).toHaveLength(0); + }); + + it('records the current test context on promises created inside it', async () => { + installPromiseTracker(); + + await withPromiseTrackerTestContext( + { + file: 'example.harness.ts', + suite: 'Example suite', + name: 'waits forever', + fullName: 'Example suite waits forever', + }, + async () => { + void new Promise(() => undefined); + } + ); + + expect(getPendingPromises()).toEqual([ + expect.objectContaining({ + test: { + file: 'example.harness.ts', + suite: 'Example suite', + name: 'waits forever', + fullName: 'Example suite waits forever', + }, + }), + ]); + }); + + it('clears tracked records without uninstalling the tracker', () => { + installPromiseTracker(); + + void new Promise(() => undefined); + expect(getPendingPromises()).toHaveLength(1); + + clearTrackedPromises(); + expect(getPendingPromises()).toHaveLength(0); + + void new Promise(() => undefined); + expect(getPendingPromises()).toHaveLength(1); + }); + + it('restores the original global Promise when uninstalled', () => { + const originalPromise = globalThis.Promise; + + installPromiseTracker(); + expect(globalThis.Promise).not.toBe(originalPromise); + + uninstallPromiseTracker(); + expect(globalThis.Promise).toBe(originalPromise); + }); +}); diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index 87d63fd8..d0411306 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -7,7 +7,12 @@ import { } from '../collector/index.js'; import type { HarnessTestContext } from '@react-native-harness/bridge'; import { getTestRunner } from '../runner/index.js'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach as vitestAfterEach, describe, expect, it, vi } from 'vitest'; +import { + getPendingPromises, + installPromiseTracker, + uninstallPromiseTracker, +} from '../promise-tracker.js'; vi.mock('../symbolicate.js', async () => { return { @@ -24,6 +29,10 @@ const getTaskContext = (context: HarnessTestContext) => { }; describe('runner task context', () => { + vitestAfterEach(() => { + uninstallPromiseTracker(); + }); + it('passes minimal task metadata to tests and per-test hooks', async () => { const observedTasks: Array<{ source: 'beforeEach' | 'test' | 'afterEach'; @@ -42,7 +51,10 @@ describe('runner task context', () => { const collection = await collector.collect(() => { harnessDescribe('Task Context Suite', () => { beforeEach((context: HarnessTestContext) => { - observedTasks.push({ source: 'beforeEach', task: getTask(context) }); + observedTasks.push({ + source: 'beforeEach', + task: getTask(context), + }); }); afterEach((context: HarnessTestContext) => { @@ -104,6 +116,44 @@ describe('runner task context', () => { } }); + it('tags pending promises with the currently running test', async () => { + installPromiseTracker(); + + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Promise Suite', () => { + harnessIt('leaves promise pending', () => { + void new Promise(() => undefined); + }); + }); + }, 'runtime/promises.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/promises.test.ts', + runner: 'ios', + }); + + expect(result.suites[0].tests[0]).toMatchObject({ status: 'passed' }); + expect(getPendingPromises()).toEqual([ + expect.objectContaining({ + test: { + file: 'runtime/promises.test.ts', + suite: 'Promise Suite', + name: 'leaves promise pending', + fullName: 'Promise Suite leaves promise pending', + }, + }), + ]); + } finally { + collector.dispose(); + runner.dispose(); + } + }); + it('keeps zero-argument tests and hooks working', async () => { const calls: string[] = []; const collector = getTestCollector(); @@ -196,13 +246,16 @@ describe('runner task context', () => { try { const collection = await collector.collect(() => { harnessDescribe('Conditional Skip Suite', () => { - harnessIt('continues when condition is false', (context: HarnessTestContext) => { - const { skip } = getTaskContext(context); + harnessIt( + 'continues when condition is false', + (context: HarnessTestContext) => { + const { skip } = getTaskContext(context); - calls.push('before'); - skip(false, 'do not skip'); - calls.push('after'); - }); + calls.push('before'); + skip(false, 'do not skip'); + calls.push('after'); + } + ); }); }, 'runtime/conditional-skip.test.ts'); @@ -232,18 +285,21 @@ describe('runner task context', () => { calls.push('afterEach'); }); - harnessIt('runs finished callbacks', (context: HarnessTestContext) => { - const { onTestFinished } = getTaskContext(context); + harnessIt( + 'runs finished callbacks', + (context: HarnessTestContext) => { + const { onTestFinished } = getTaskContext(context); - onTestFinished(() => { - calls.push('onTestFinished:first'); - }); - onTestFinished(() => { - calls.push('onTestFinished:second'); - }); + onTestFinished(() => { + calls.push('onTestFinished:first'); + }); + onTestFinished(() => { + calls.push('onTestFinished:second'); + }); - calls.push('test'); - }); + calls.push('test'); + } + ); }); }, 'runtime/on-test-finished-pass.test.ts'); @@ -283,13 +339,13 @@ describe('runner task context', () => { (context: HarnessTestContext) => { const { onTestFinished, skip } = getTaskContext(context); - onTestFinished(() => { - calls.push('onTestFinished'); - }); + onTestFinished(() => { + calls.push('onTestFinished'); + }); - calls.push('before-skip'); - skip(); - }, + calls.push('before-skip'); + skip(); + } ); }); }, 'runtime/on-test-finished-skip.test.ts'); @@ -325,13 +381,13 @@ describe('runner task context', () => { (context: HarnessTestContext) => { const { onTestFinished } = getTaskContext(context); - onTestFinished(() => { - calls.push('onTestFinished'); - }); + onTestFinished(() => { + calls.push('onTestFinished'); + }); - calls.push('test'); - throw new Error('expected failure'); - }, + calls.push('test'); + throw new Error('expected failure'); + } ); }); }, 'runtime/on-test-finished-failure.test.ts'); @@ -414,13 +470,13 @@ describe('runner task context', () => { (context: HarnessTestContext) => { const { onTestFailed, skip } = getTaskContext(context); - onTestFailed(() => { - calls.push('onTestFailed'); - }); + onTestFailed(() => { + calls.push('onTestFailed'); + }); - calls.push('before-skip'); - skip(); - }, + calls.push('before-skip'); + skip(); + } ); }); }, 'runtime/on-test-failed-skip.test.ts'); @@ -506,12 +562,12 @@ describe('runner task context', () => { (context: HarnessTestContext) => { const { onTestFailed } = getTaskContext(context); - onTestFailed(() => { - calls.push('onTestFailed'); - }); + onTestFailed(() => { + calls.push('onTestFailed'); + }); - calls.push('test'); - }, + calls.push('test'); + } ); }); }, 'runtime/on-test-failed-after-each.test.ts'); diff --git a/packages/runtime/src/client/factory.ts b/packages/runtime/src/client/factory.ts index 14a05830..a63e5e6b 100644 --- a/packages/runtime/src/client/factory.ts +++ b/packages/runtime/src/client/factory.ts @@ -4,7 +4,10 @@ import type { BundlerEvents, TestExecutionOptions, } from '@react-native-harness/bridge'; -import { connectToHarness, type HarnessHandle } from '@react-native-harness/bridge/client'; +import { + connectToHarness, + type HarnessHandle, +} from '@react-native-harness/bridge/client'; import { store } from '../ui/state.js'; import { getTestRunner, TestRunner } from '../runner/index.js'; import { getTestCollector, TestCollector } from '../collector/index.js'; @@ -15,10 +18,13 @@ import { markTestsAsSkippedByName } from '../filtering/index.js'; import { setup } from '../render/setup.js'; import { runSetupFiles } from './setup-files.js'; import { setHandle } from './store.js'; +import { installPromiseTracker } from '../promise-tracker.js'; export const getClient = async (): Promise => { const handle = await connectToHarness(getWSServer(), { runTests: async (path: string, options: TestExecutionOptions) => { + installPromiseTracker(); + if (store.getState().status === 'running') { throw new Error('Already running tests'); } @@ -39,7 +45,7 @@ export const getClient = async (): Promise => { events = combineEventEmitters( collector.events, runner.events, - bundler.events, + bundler.events ); events.addListener((event) => { @@ -71,7 +77,7 @@ export const getClient = async (): Promise => { const processedTestSuite = options.testNamePattern ? markTestsAsSkippedByName( collectionResult.testSuite, - options.testNamePattern, + options.testNamePattern ) : collectionResult.testSuite; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index deac01f1..068dce02 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -10,3 +10,4 @@ export * from './mocker/index.js'; export * from './namespace.js'; export * from './waitFor.js'; export * from './render/index.js'; +export * from './promise-tracker.js'; diff --git a/packages/runtime/src/promise-tracker.ts b/packages/runtime/src/promise-tracker.ts new file mode 100644 index 00000000..e91305ef --- /dev/null +++ b/packages/runtime/src/promise-tracker.ts @@ -0,0 +1,139 @@ +export type PromiseTrackerTestContext = { + file: string; + suite: string; + name: string; + fullName: string; +}; + +export type TrackedPromiseRecord = { + id: number; + createdAt: number; + stack?: string; + test?: PromiseTrackerTestContext; +}; + +type PromiseResolve = (value: T | PromiseLike) => void; +type PromiseReject = (reason?: unknown) => void; +type PromiseExecutor = ( + resolve: PromiseResolve, + reject: PromiseReject +) => void; + +const pendingPromises = new Map(); + +let originalPromise: PromiseConstructor | null = null; +let nextPromiseId = 1; +let currentTestContext: PromiseTrackerTestContext | undefined; + +const getOriginalPromise = (): PromiseConstructor => + originalPromise ?? globalThis.Promise; + +const createPromiseStack = (): string | undefined => { + try { + return new Error('Promise created').stack; + } catch { + return undefined; + } +}; + +const registerPromise = (): number => { + const id = nextPromiseId++; + + pendingPromises.set(id, { + id, + createdAt: Date.now(), + stack: createPromiseStack(), + test: currentTestContext, + }); + + return id; +}; + +const markPromiseSettled = (id: number) => { + pendingPromises.delete(id); +}; + +const createTrackedPromiseConstructor = (): PromiseConstructor => { + const NativePromise = getOriginalPromise(); + + class TrackedPromise extends NativePromise { + constructor(executor: PromiseExecutor) { + const id = registerPromise(); + let settled = false; + + const settle = () => { + if (settled) { + return; + } + + settled = true; + markPromiseSettled(id); + }; + + super((resolve, reject) => { + try { + executor( + (value: T | PromiseLike) => { + settle(); + resolve(value); + }, + (reason?: unknown) => { + settle(); + reject(reason); + } + ); + } catch (error) { + settle(); + throw error; + } + }); + } + } + + return TrackedPromise as PromiseConstructor; +}; + +export const installPromiseTracker = (): void => { + if (originalPromise) { + return; + } + + originalPromise = globalThis.Promise; + globalThis.Promise = createTrackedPromiseConstructor(); +}; + +export const uninstallPromiseTracker = (): void => { + if (!originalPromise) { + return; + } + + globalThis.Promise = originalPromise; + originalPromise = null; + pendingPromises.clear(); + currentTestContext = undefined; +}; + +export const clearTrackedPromises = (): void => { + pendingPromises.clear(); +}; + +export const getPendingPromises = (): TrackedPromiseRecord[] => { + return [...pendingPromises.values()].map((record) => ({ + ...record, + test: record.test ? { ...record.test } : undefined, + })); +}; + +export const withPromiseTrackerTestContext = async ( + context: PromiseTrackerTestContext, + work: () => Promise +): Promise => { + const previousContext = currentTestContext; + currentTestContext = context; + + try { + return await work(); + } finally { + currentTestContext = previousContext; + } +}; diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index 1ec71d2f..6f2e2647 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -13,6 +13,7 @@ import { flushExpectTestState } from '../expect/errors.js'; import { runHooks } from './hooks.js'; import { getTestExecutionError } from './errors.js'; import { ActiveTestContext, TestRunnerContext } from './types.js'; +import { withPromiseTrackerTestContext } from '../promise-tracker.js'; import { createTestContext, createTestLifecycleState, @@ -51,7 +52,7 @@ const emitTestFinished = ( duration: number; status: 'passed' | 'failed' | 'skipped' | 'todo'; error?: TestResult['error']; - }, + } ) => { const ancestorTitles = getAncestorTitles(options.suite); @@ -77,7 +78,7 @@ declare global { const runTest = async ( test: TestCase, suite: TestSuite, - context: TestRunnerContext, + context: TestRunnerContext ): Promise => { const startedAt = Date.now(); const task: HarnessTaskContext = { @@ -87,8 +88,8 @@ const runTest = async ( test.status === 'active' ? 'run' : test.status === 'skipped' - ? 'skip' - : 'todo', + ? 'skip' + : 'todo', file: { name: context.testFilePath, }, @@ -99,7 +100,7 @@ const runTest = async ( const lifecycleState = createTestLifecycleState(); const activeTestContext: ActiveTestContext = createTestContext( task, - lifecycleState, + lifecycleState ); // Emit test-started event @@ -165,24 +166,35 @@ const runTest = async ( setCurrentExpectTestState(expectTestState); try { + const fullName = getFullName(ancestorTitles, test.name); let didSkip = false; - try { - // Run all beforeEach hooks from the current suite and its parents - await runHooks(suite, 'beforeEach', activeTestContext); - - // Run the actual test - await test.fn(activeTestContext); - } catch (error) { - if (!isSkipTestError(error)) { - throw error; + await withPromiseTrackerTestContext( + { + file: context.testFilePath, + suite: suite.name, + name: test.name, + fullName, + }, + async () => { + try { + // Run all beforeEach hooks from the current suite and its parents + await runHooks(suite, 'beforeEach', activeTestContext); + + // Run the actual test + await test.fn(activeTestContext); + } catch (error) { + if (!isSkipTestError(error)) { + throw error; + } + + didSkip = true; + } finally { + // Run all afterEach hooks from the current suite and its parents + await runHooks(suite, 'afterEach', activeTestContext); + } } - - didSkip = true; - } finally { - // Run all afterEach hooks from the current suite and its parents - await runHooks(suite, 'afterEach', activeTestContext); - } + ); if (didSkip) { const duration = Date.now() - startedAt; @@ -245,7 +257,7 @@ const runTest = async ( error, context.testFilePath, suite.name, - test.name, + test.name ); const duration = Date.now() - startedAt; @@ -275,7 +287,7 @@ const runTest = async ( export const runSuite = async ( suite: TestSuite, - context: TestRunnerContext, + context: TestRunnerContext ): Promise => { const startTime = Date.now(); @@ -289,12 +301,14 @@ export const runSuite = async ( // Check if suite should be skipped or is todo if (suite.status === 'skipped') { const testResults = await Promise.all( - suite.tests.map((test) => runTest({ ...test, status: 'skipped' }, suite, context)), + suite.tests.map((test) => + runTest({ ...test, status: 'skipped' }, suite, context) + ) ); const suiteResults = await Promise.all( suite.suites.map((childSuite) => - runSuite({ ...childSuite, status: 'skipped' }, context), - ), + runSuite({ ...childSuite, status: 'skipped' }, context) + ) ); const result = { @@ -366,10 +380,10 @@ export const runSuite = async ( // Check if any tests or child suites failed const hasFailedTests = testResults.some( - (result) => result.status === 'failed', + (result) => result.status === 'failed' ); const hasFailedSuites = suiteResults.some( - (result) => result.status === 'failed', + (result) => result.status === 'failed' ); if (hasFailedTests || hasFailedSuites) { From 31cc0220af3acd47ef4bc299c30659ddca84ece5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 2 Jun 2026 21:10:20 +0200 Subject: [PATCH 02/11] Handle test case timeouts in runtime --- .../bridge/src/__tests__/rpc-peer.test.ts | 25 ++ packages/bridge/src/rpc-peer.ts | 11 +- packages/bridge/src/server.ts | 2 +- packages/bridge/src/shared.ts | 1 + .../jest/src/__tests__/execute-run.test.ts | 48 +++- packages/jest/src/execute-run.ts | 10 +- packages/jest/src/run.ts | 6 + .../src/__tests__/runner-context.test.ts | 75 ++++++ packages/runtime/src/client/factory.ts | 1 + packages/runtime/src/promise-tracker.ts | 23 +- packages/runtime/src/runner/factory.ts | 3 +- packages/runtime/src/runner/runSuite.ts | 220 +++++++++++++++--- packages/runtime/src/runner/types.ts | 2 + 13 files changed, 387 insertions(+), 40 deletions(-) diff --git a/packages/bridge/src/__tests__/rpc-peer.test.ts b/packages/bridge/src/__tests__/rpc-peer.test.ts index 31c770de..544fac0d 100644 --- a/packages/bridge/src/__tests__/rpc-peer.test.ts +++ b/packages/bridge/src/__tests__/rpc-peer.test.ts @@ -168,6 +168,31 @@ describe('rpc-peer', () => { await expect(pending).rejects.toThrow('timed out'); }); + it('does not time out calls when method-specific timeout returns undefined', async () => { + const peer = createRpcPeer< + Record, + { runTests: (path: string, options: { runner: string }) => Promise }, + BridgeEvents + >({ + localMethods: {}, + transport: createMockTransport(), + callTimeoutMs: () => undefined, + createTimeoutError: () => new Error('timed out'), + }); + + let rejected = false; + const pending = peer.invoke('runTests', 'example.ts', { runner: '/runner.js' }); + pending.catch(() => { + rejected = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(rejected).toBe(false); + peer.close(new Error('closed')); + await expect(pending).rejects.toThrow('closed'); + }); + it('throws on malformed messages', async () => { const peer = createRpcPeer, Record, BridgeEvents>({ localMethods: {}, diff --git a/packages/bridge/src/rpc-peer.ts b/packages/bridge/src/rpc-peer.ts index e4ca7144..ed0dbe58 100644 --- a/packages/bridge/src/rpc-peer.ts +++ b/packages/bridge/src/rpc-peer.ts @@ -40,7 +40,7 @@ export type CreateRpcPeerOptions< localMethods: Local; transport: RpcTransport; onEvent?: (event: Event) => void; - callTimeoutMs?: number; + callTimeoutMs?: number | ((method: string, args: unknown[]) => number | undefined); createTimeoutError?: (method: string, args: unknown[]) => Error; }; @@ -107,14 +107,19 @@ export const createRpcPeer = < timeout: null, }; - if (options.callTimeoutMs !== undefined) { + const callTimeoutMs = + typeof options.callTimeoutMs === 'function' + ? options.callTimeoutMs(methodName, args) + : options.callTimeoutMs; + + if (callTimeoutMs !== undefined) { invocation.timeout = setTimeout(() => { pendingInvocations.delete(id); reject( options.createTimeoutError?.(methodName, args) ?? new Error(`RPC call timed out: ${methodName}`), ); - }, options.callTimeoutMs); + }, callTimeoutMs); } pendingInvocations.set(id, invocation); diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts index 3cded618..70928b51 100644 --- a/packages/bridge/src/server.ts +++ b/packages/bridge/src/server.ts @@ -160,7 +160,7 @@ export const createHarnessBridge = async ( onEvent: (event) => { emitter.emit('event', event); }, - callTimeoutMs: timeout, + callTimeoutMs: (method) => method === 'runTests' ? undefined : timeout, createTimeoutError: (functionName, args) => { return new DeviceNotRespondingError(functionName, args) as unknown as Error; }, diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts index b83287c3..a97eee28 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -134,6 +134,7 @@ export type TestExecutionOptions = { testNamePattern?: string; setupFiles?: string[]; setupFilesAfterEnv?: string[]; + testTimeout?: number; runner: string; }; diff --git a/packages/jest/src/__tests__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 632ce142..8d8d1a2e 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -192,7 +192,7 @@ describe('executeRun', () => { const session = makeSession({ ensureAppReady: vi.fn(async () => { order.push('ensureAppReady'); }), }); - const emitEvent: EmitEvent = (async (eventName, ..._eventData) => { + const emitEvent: EmitEvent = (async (eventName) => { if (eventName === 'test-file-start') { order.push('test-file-start'); } @@ -461,6 +461,52 @@ describe('executeRun', () => { // restartApp should be called for tests 2 and 3, not test 1. expect(session.restartApp).toHaveBeenCalledTimes(2); }); + + it('restarts before the next runnable file after a test case timeout', async () => { + const timedOutResult = makeHarnessResult('failed'); + timedOutResult.tests = [ + { + name: 'hangs', + status: 'failed', + duration: 10, + error: { + name: 'TestCaseTimeoutError', + message: 'Test timed out after 10ms: hangs', + }, + }, + ]; + mockRunHarnessTestFile + .mockResolvedValueOnce(makeFileRunResult({ + harnessResult: timedOutResult, + jestResult: makeJestResult({ + numFailingTests: 1, + numPassingTests: 0, + }), + })) + .mockResolvedValueOnce(makeFileRunResult()); + const session = makeSession({ + config: { + metroPort: 8081, + resetEnvironmentBetweenTestFiles: false, + detectNativeCrashes: false, + runners: [ + { platformId: 'android', name: 'android' }, + { platformId: 'ios', name: 'ios' }, + ], + } as HarnessSession['config'], + }); + + await executeRun( + session, + [makeTest('/a.ts'), makeTest('/b.ts')], + makeWatcher(), + makeEmitEvent().emitEvent, + makeGlobalConfig(), + ); + + expect(session.restartApp).toHaveBeenCalledTimes(1); + expect(session.restartApp).toHaveBeenCalledWith('/b.ts'); + }); }); describe('platform-specific test files', () => { diff --git a/packages/jest/src/execute-run.ts b/packages/jest/src/execute-run.ts index 6d326bba..05fb94fd 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -22,6 +22,7 @@ import type { TestRunnerEvents, TestRunnerTestFinishedEvent, TestRunnerTestStartedEvent, + TestSuiteResult, } from '@react-native-harness/bridge'; import { createPlatformSkippedTestResult, @@ -114,6 +115,10 @@ const isHarnessCaseEvent = ( ): event is TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent => event.type === 'test-started' || event.type === 'test-finished'; +const hasTestCaseTimeout = (result: TestSuiteResult): boolean => + result.tests.some((test) => test.error?.name === 'TestCaseTimeoutError') || + result.suites.some(hasTestCaseTimeout); + export const executeRun = async ( session: HarnessSession, tests: Array, @@ -174,6 +179,7 @@ export const executeRun = async ( session.config.runners.map((runner) => runner.platformId), ); let isFirstTest = true; + let shouldRestartAfterTimeout = false; let runError: unknown; try { @@ -230,8 +236,9 @@ export const executeRun = async ( } try { - if (shouldResetEnv && !isFirstTest) { + if ((shouldResetEnv && !isFirstTest) || shouldRestartAfterTimeout) { await session.restartApp(test.path); + shouldRestartAfterTimeout = false; } isFirstTest = false; @@ -259,6 +266,7 @@ export const executeRun = async ( duration: result.duration, result: result.harnessResult, }); + shouldRestartAfterTimeout = hasTestCaseTimeout(result.harnessResult); await caseEventChain; await emitEvent('test-file-success', test, result.jestResult); } catch (err) { diff --git a/packages/jest/src/run.ts b/packages/jest/src/run.ts index 43548ffb..9c05838c 100644 --- a/packages/jest/src/run.ts +++ b/packages/jest/src/run.ts @@ -88,11 +88,17 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({ const setupFilesAfterEnv = projectConfig.setupFilesAfterEnv?.map( (setupFile) => path.relative(globalConfig.rootDir, setupFile) ); + const testTimeout = + (projectConfig as JestConfig.ProjectConfig & { testTimeout?: number }) + .testTimeout ?? + (globalConfig as JestConfig.GlobalConfig & { testTimeout?: number }) + .testTimeout; const harnessResult = await session.runTestFile(relativeTestPath, { testNamePattern: globalConfig.testNamePattern, setupFiles, setupFilesAfterEnv, + testTimeout, runner: session.context.platform.runner, }); const end = Date.now(); diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index d0411306..a8ee9a3f 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -585,4 +585,79 @@ describe('runner task context', () => { runner.dispose(); } }); + + it('fails the timed-out test and skips the rest of the file', async () => { + const calls: string[] = []; + const events: Array<{ type: string; name?: string; status?: string }> = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + runner.events.addListener((event) => { + if (event.type === 'test-finished') { + events.push({ + type: event.type, + name: event.name, + status: event.status, + }); + } + }); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Timeout Suite', () => { + harnessIt('hangs', async () => { + calls.push('hangs'); + await new Promise(() => undefined); + }); + + harnessIt('does not run', () => { + calls.push('does not run'); + }); + + harnessIt.todo('keeps todo status'); + + harnessDescribe('Nested Suite', () => { + harnessIt('also does not run', () => { + calls.push('nested'); + }); + }); + }); + }, 'runtime/timeout.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/timeout.test.ts', + runner: 'ios', + testTimeout: 10, + }); + + expect(result.status).toBe('failed'); + expect(result.suites[0].tests).toMatchObject([ + { + name: 'hangs', + status: 'failed', + error: { + name: 'TestCaseTimeoutError', + message: expect.stringContaining('Timeout Suite hangs'), + }, + }, + { name: 'does not run', status: 'skipped' }, + { name: 'keeps todo status', status: 'todo' }, + ]); + expect(result.suites[0].suites[0]).toMatchObject({ + name: 'Nested Suite', + status: 'skipped', + tests: [{ name: 'also does not run', status: 'skipped' }], + }); + expect(calls).toEqual(['hangs']); + expect(events).toEqual([ + { type: 'test-finished', name: 'hangs', status: 'failed' }, + { type: 'test-finished', name: 'does not run', status: 'skipped' }, + { type: 'test-finished', name: 'keeps todo status', status: 'todo' }, + { type: 'test-finished', name: 'also does not run', status: 'skipped' }, + ]); + } finally { + collector.dispose(); + runner.dispose(); + } + }); }); diff --git a/packages/runtime/src/client/factory.ts b/packages/runtime/src/client/factory.ts index a63e5e6b..ec39bae9 100644 --- a/packages/runtime/src/client/factory.ts +++ b/packages/runtime/src/client/factory.ts @@ -85,6 +85,7 @@ export const getClient = async (): Promise => { testSuite: processedTestSuite, testFilePath: path, runner: options.runner, + testTimeout: options.testTimeout, }); } finally { collector?.dispose(); diff --git a/packages/runtime/src/promise-tracker.ts b/packages/runtime/src/promise-tracker.ts index e91305ef..d1ff256d 100644 --- a/packages/runtime/src/promise-tracker.ts +++ b/packages/runtime/src/promise-tracker.ts @@ -24,6 +24,7 @@ const pendingPromises = new Map(); let originalPromise: PromiseConstructor | null = null; let nextPromiseId = 1; let currentTestContext: PromiseTrackerTestContext | undefined; +let trackingDisabledDepth = 0; const getOriginalPromise = (): PromiseConstructor => originalPromise ?? globalThis.Promise; @@ -36,7 +37,11 @@ const createPromiseStack = (): string | undefined => { } }; -const registerPromise = (): number => { +const registerPromise = (): number | null => { + if (trackingDisabledDepth > 0) { + return null; + } + const id = nextPromiseId++; pendingPromises.set(id, { @@ -49,7 +54,11 @@ const registerPromise = (): number => { return id; }; -const markPromiseSettled = (id: number) => { +const markPromiseSettled = (id: number | null) => { + if (id === null) { + return; + } + pendingPromises.delete(id); }; @@ -137,3 +146,13 @@ export const withPromiseTrackerTestContext = async ( currentTestContext = previousContext; } }; + +export const runWithoutPromiseTracking = (work: () => T): T => { + trackingDisabledDepth += 1; + + try { + return work(); + } finally { + trackingDisabledDepth -= 1; + } +}; diff --git a/packages/runtime/src/runner/factory.ts b/packages/runtime/src/runner/factory.ts index 49c3be16..da45ba7c 100644 --- a/packages/runtime/src/runner/factory.ts +++ b/packages/runtime/src/runner/factory.ts @@ -9,7 +9,7 @@ export const getTestRunner = (): TestRunner => { return { events, - run: async ({ testSuite, testFilePath, runner }) => { + run: async ({ testSuite, testFilePath, runner, testTimeout }) => { setHarnessContext({ testFilePath, runner, @@ -18,6 +18,7 @@ export const getTestRunner = (): TestRunner => { const result = await runSuite(testSuite, { events, testFilePath, + testTimeout, }); // If coverage is enabled, there will be a global variable called __coverage__ diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index 6f2e2647..25bd45db 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -13,7 +13,10 @@ import { flushExpectTestState } from '../expect/errors.js'; import { runHooks } from './hooks.js'; import { getTestExecutionError } from './errors.js'; import { ActiveTestContext, TestRunnerContext } from './types.js'; -import { withPromiseTrackerTestContext } from '../promise-tracker.js'; +import { + runWithoutPromiseTracking, + withPromiseTrackerTestContext, +} from '../promise-tracker.js'; import { createTestContext, createTestLifecycleState, @@ -43,6 +46,59 @@ const getAncestorTitles = (suite: TestSuite): string[] => { const getFullName = (ancestorTitles: string[], testName: string): string => [...ancestorTitles, testName].join(' '); +const DEFAULT_TEST_TIMEOUT_MS = 5_000; + +export class TestCaseTimeoutError extends Error { + constructor( + public readonly testName: string, + public readonly timeout: number, + ) { + super(`Test timed out after ${timeout}ms: ${testName}`); + this.name = 'TestCaseTimeoutError'; + } +} + +type RunSuiteState = { + interruptedByTimeout: boolean; +}; + +const getTestTimeout = (context: TestRunnerContext): number => { + const timeout = context.testTimeout ?? DEFAULT_TEST_TIMEOUT_MS; + return Number.isFinite(timeout) && timeout > 0 + ? timeout + : DEFAULT_TEST_TIMEOUT_MS; +}; + +const withTestTimeout = async ( + work: () => Promise, + options: { + fullName: string; + timeout: number; + }, +): Promise => { + let timeoutId: ReturnType | null = null; + + const timeoutPromise = runWithoutPromiseTracking( + () => + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new TestCaseTimeoutError(options.fullName, options.timeout)); + }, options.timeout); + }), + ); + const workPromise = work(); + + try { + return await runWithoutPromiseTracking(() => + Promise.race([workPromise, timeoutPromise]), + ); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +}; + const emitTestFinished = ( context: TestRunnerContext, options: { @@ -71,6 +127,85 @@ const emitTestFinished = ( }); }; +const createSkippedTestResult = ( + test: TestCase, + suite: TestSuite, + context: TestRunnerContext, +): TestResult => { + const startedAt = Date.now(); + const ancestorTitles = getAncestorTitles(suite); + const fullName = getFullName(ancestorTitles, test.name); + const status: TestResult['status'] = + test.status === 'todo' ? 'todo' : 'skipped'; + + context.events.emit({ + type: 'test-started', + name: test.name, + suite: suite.name, + file: context.testFilePath, + ancestorTitles, + fullName, + startedAt, + declarationMode: test.declarationMode, + }); + + const result = { + name: test.name, + status, + duration: 0, + ancestorTitles, + fullName, + startedAt, + declarationMode: test.declarationMode, + }; + + emitTestFinished(context, { + test, + suite, + startedAt, + duration: 0, + status, + }); + + return result; +}; + +const createSkippedSuiteResult = ( + suite: TestSuite, + context: TestRunnerContext, +): TestSuiteResult => { + context.events.emit({ + type: 'suite-started', + name: suite.name, + file: context.testFilePath, + }); + + const testResults = suite.tests.map((test) => + createSkippedTestResult(test, suite, context), + ); + const suiteResults = suite.suites.map((childSuite) => + createSkippedSuiteResult(childSuite, context), + ); + + const result = { + name: suite.name, + tests: testResults, + suites: suiteResults, + status: 'skipped' as const, + duration: 0, + }; + + context.events.emit({ + type: 'suite-finished', + file: context.testFilePath, + name: suite.name, + duration: 0, + status: 'skipped', + }); + + return result; +}; + declare global { var HARNESS_TEST_PATH: string; } @@ -78,7 +213,8 @@ declare global { const runTest = async ( test: TestCase, suite: TestSuite, - context: TestRunnerContext + context: TestRunnerContext, + state: RunSuiteState ): Promise => { const startedAt = Date.now(); const task: HarnessTaskContext = { @@ -169,31 +305,44 @@ const runTest = async ( const fullName = getFullName(ancestorTitles, test.name); let didSkip = false; - await withPromiseTrackerTestContext( - { - file: context.testFilePath, - suite: suite.name, - name: test.name, - fullName, - }, + await withTestTimeout( async () => { - try { - // Run all beforeEach hooks from the current suite and its parents - await runHooks(suite, 'beforeEach', activeTestContext); - - // Run the actual test - await test.fn(activeTestContext); - } catch (error) { - if (!isSkipTestError(error)) { - throw error; + await withPromiseTrackerTestContext( + { + file: context.testFilePath, + suite: suite.name, + name: test.name, + fullName, + }, + async () => { + try { + // Run all beforeEach hooks from the current suite and its parents + await runHooks(suite, 'beforeEach', activeTestContext); + + // Run the actual test + await test.fn(activeTestContext); + } catch (error) { + if (!isSkipTestError(error)) { + throw error; + } + + didSkip = true; + } finally { + // Run all afterEach hooks from the current suite and its parents + await runHooks(suite, 'afterEach', activeTestContext); + } } + ); - didSkip = true; - } finally { - // Run all afterEach hooks from the current suite and its parents - await runHooks(suite, 'afterEach', activeTestContext); + if (!didSkip) { + await flushExpectTestState(expectTestState); + await runOnTestFinished(lifecycleState); } - } + }, + { + fullName, + timeout: getTestTimeout(context), + }, ); if (didSkip) { @@ -222,8 +371,6 @@ const runTest = async ( return result; } - await flushExpectTestState(expectTestState); - await runOnTestFinished(lifecycleState); } finally { setCurrentExpectTestState(undefined); } @@ -250,6 +397,10 @@ const runTest = async ( return result; } catch (error) { + if (error instanceof TestCaseTimeoutError) { + state.interruptedByTimeout = true; + } + await runOnTestFailed(lifecycleState); await runOnTestFinished(lifecycleState); @@ -287,7 +438,8 @@ const runTest = async ( export const runSuite = async ( suite: TestSuite, - context: TestRunnerContext + context: TestRunnerContext, + state: RunSuiteState = { interruptedByTimeout: false } ): Promise => { const startTime = Date.now(); @@ -302,12 +454,12 @@ export const runSuite = async ( if (suite.status === 'skipped') { const testResults = await Promise.all( suite.tests.map((test) => - runTest({ ...test, status: 'skipped' }, suite, context) + runTest({ ...test, status: 'skipped' }, suite, context, state) ) ); const suiteResults = await Promise.all( suite.suites.map((childSuite) => - runSuite({ ...childSuite, status: 'skipped' }, context) + runSuite({ ...childSuite, status: 'skipped' }, context, state) ) ); @@ -360,18 +512,24 @@ export const runSuite = async ( // Run all tests in the current suite for (const test of suite.tests) { - const result = await runTest(test, suite, context); + const result = state.interruptedByTimeout + ? createSkippedTestResult(test, suite, context) + : await runTest(test, suite, context, state); testResults.push(result); } // Run all child suites for (const childSuite of suite.suites) { - const result = await runSuite(childSuite, context); + const result = state.interruptedByTimeout + ? createSkippedSuiteResult(childSuite, context) + : await runSuite(childSuite, context, state); suiteResults.push(result); } // Run afterAll hooks - await runHooks(suite, 'afterAll'); + if (!state.interruptedByTimeout) { + await runHooks(suite, 'afterAll'); + } const duration = Date.now() - startTime; diff --git a/packages/runtime/src/runner/types.ts b/packages/runtime/src/runner/types.ts index 2ed1d912..6b4a624c 100644 --- a/packages/runtime/src/runner/types.ts +++ b/packages/runtime/src/runner/types.ts @@ -11,6 +11,7 @@ export type TestRunnerEventsEmitter = EventEmitter; export type TestRunnerContext = { events: TestRunnerEventsEmitter; testFilePath: string; + testTimeout?: number; }; export type ActiveTestContext = HarnessTestContext; @@ -19,6 +20,7 @@ export type RunTestsOptions = { testSuite: TestSuite; testFilePath: string; runner: string; + testTimeout?: number; }; export type TestRunner = { From 6a05aeb886ca422e71685e9a97906c1c4234f86d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 2 Jun 2026 21:26:02 +0200 Subject: [PATCH 03/11] Fix timeout cleanup regressions --- .../jest/src/__tests__/execute-run.test.ts | 48 ++++++++++++++++++- packages/jest/src/execute-run.ts | 7 ++- .../src/__tests__/promise-tracker.test.ts | 9 ++++ .../src/__tests__/runner-context.test.ts | 41 ++++++++++++++++ packages/runtime/src/promise-tracker.ts | 32 ++++++++----- packages/runtime/src/runner/runSuite.ts | 20 ++++++-- 6 files changed, 138 insertions(+), 19 deletions(-) diff --git a/packages/jest/src/__tests__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 8d8d1a2e..265aead1 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -462,7 +462,7 @@ describe('executeRun', () => { expect(session.restartApp).toHaveBeenCalledTimes(2); }); - it('restarts before the next runnable file after a test case timeout', async () => { + it('restarts after a test case timeout before the next runnable file', async () => { const timedOutResult = makeHarnessResult('failed'); timedOutResult.tests = [ { @@ -505,7 +505,51 @@ describe('executeRun', () => { ); expect(session.restartApp).toHaveBeenCalledTimes(1); - expect(session.restartApp).toHaveBeenCalledWith('/b.ts'); + expect(session.restartApp).toHaveBeenCalledWith('/a.ts'); + }); + + it('restarts after a timeout in the last runnable file', async () => { + const timedOutResult = makeHarnessResult('failed'); + timedOutResult.tests = [ + { + name: 'hangs', + status: 'failed', + duration: 10, + error: { + name: 'TestCaseTimeoutError', + message: 'Test timed out after 10ms: hangs', + }, + }, + ]; + mockRunHarnessTestFile.mockResolvedValueOnce(makeFileRunResult({ + harnessResult: timedOutResult, + jestResult: makeJestResult({ + numFailingTests: 1, + numPassingTests: 0, + }), + })); + const session = makeSession({ + config: { + metroPort: 8081, + resetEnvironmentBetweenTestFiles: false, + detectNativeCrashes: false, + runners: [ + { platformId: 'android', name: 'android' }, + { platformId: 'ios', name: 'ios' }, + ], + } as HarnessSession['config'], + }); + + await executeRun( + session, + [makeTest('/a.ts')], + makeWatcher(), + makeEmitEvent().emitEvent, + makeGlobalConfig({ watch: true }), + ); + + expect(session.restartApp).toHaveBeenCalledTimes(1); + expect(session.restartApp).toHaveBeenCalledWith('/a.ts'); }); }); diff --git a/packages/jest/src/execute-run.ts b/packages/jest/src/execute-run.ts index 05fb94fd..73ac12f6 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -266,9 +266,14 @@ export const executeRun = async ( duration: result.duration, result: result.harnessResult, }); - shouldRestartAfterTimeout = hasTestCaseTimeout(result.harnessResult); + const didTestCaseTimeout = hasTestCaseTimeout(result.harnessResult); + shouldRestartAfterTimeout = didTestCaseTimeout; await caseEventChain; await emitEvent('test-file-success', test, result.jestResult); + if (didTestCaseTimeout) { + await session.restartApp(test.path); + shouldRestartAfterTimeout = false; + } } catch (err) { if (!emittedTestFileFinished) { await emitTestFileFinished({ diff --git a/packages/runtime/src/__tests__/promise-tracker.test.ts b/packages/runtime/src/__tests__/promise-tracker.test.ts index 9773f69e..d0c0c88b 100644 --- a/packages/runtime/src/__tests__/promise-tracker.test.ts +++ b/packages/runtime/src/__tests__/promise-tracker.test.ts @@ -34,6 +34,15 @@ describe('promise tracker', () => { expect(getPendingPromises()).toHaveLength(0); }); + it('keeps promises pending while their resolved thenable is pending', () => { + installPromiseTracker(); + + const pendingThenable = new Promise(() => undefined); + void new Promise((resolve) => resolve(pendingThenable)); + + expect(getPendingPromises()).toHaveLength(2); + }); + it('removes promises when they reject', async () => { installPromiseTracker(); diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index a8ee9a3f..9a6960a3 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -660,4 +660,45 @@ describe('runner task context', () => { runner.dispose(); } }); + + it('runs onTestFinished only once when a timed-out test eventually resumes', async () => { + const calls: string[] = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Timeout Resume Suite', () => { + harnessIt( + 'times out then resumes', + async (context: HarnessTestContext) => { + context.onTestFinished(() => { + calls.push('onTestFinished'); + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + } + ); + }); + }, 'runtime/timeout-resume.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/timeout-resume.test.ts', + runner: 'ios', + testTimeout: 10, + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(result.suites[0].tests[0]).toMatchObject({ + status: 'failed', + error: { name: 'TestCaseTimeoutError' }, + }); + expect(calls).toEqual(['onTestFinished']); + } finally { + collector.dispose(); + runner.dispose(); + } + }); }); diff --git a/packages/runtime/src/promise-tracker.ts b/packages/runtime/src/promise-tracker.ts index d1ff256d..ce7a0b00 100644 --- a/packages/runtime/src/promise-tracker.ts +++ b/packages/runtime/src/promise-tracker.ts @@ -62,37 +62,43 @@ const markPromiseSettled = (id: number | null) => { pendingPromises.delete(id); }; +const isThenable = (value: T | PromiseLike): value is PromiseLike => + value != null && + typeof value === 'object' && + 'then' in value && + typeof value.then === 'function'; + const createTrackedPromiseConstructor = (): PromiseConstructor => { const NativePromise = getOriginalPromise(); class TrackedPromise extends NativePromise { constructor(executor: PromiseExecutor) { const id = registerPromise(); - let settled = false; - - const settle = () => { - if (settled) { - return; - } - - settled = true; - markPromiseSettled(id); - }; super((resolve, reject) => { try { executor( (value: T | PromiseLike) => { - settle(); + if (isThenable(value)) { + runWithoutPromiseTracking(() => { + NativePromise.resolve(value).then( + () => markPromiseSettled(id), + () => markPromiseSettled(id) + ); + }); + } else { + markPromiseSettled(id); + } + resolve(value); }, (reason?: unknown) => { - settle(); + markPromiseSettled(id); reject(reason); } ); } catch (error) { - settle(); + markPromiseSettled(id); throw error; } }); diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index 25bd45db..e25122f1 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -238,6 +238,17 @@ const runTest = async ( task, lifecycleState ); + let timedOut = false; + let onTestFinishedRan = false; + + const runTestFinishedOnce = async (): Promise => { + if (onTestFinishedRan) { + return; + } + + onTestFinishedRan = true; + await runOnTestFinished(lifecycleState); + }; // Emit test-started event const ancestorTitles = getAncestorTitles(suite); @@ -336,7 +347,9 @@ const runTest = async ( if (!didSkip) { await flushExpectTestState(expectTestState); - await runOnTestFinished(lifecycleState); + if (!timedOut) { + await runTestFinishedOnce(); + } } }, { @@ -348,7 +361,7 @@ const runTest = async ( if (didSkip) { const duration = Date.now() - startedAt; - await runOnTestFinished(lifecycleState); + await runTestFinishedOnce(); const result = { name: test.name, @@ -399,10 +412,11 @@ const runTest = async ( } catch (error) { if (error instanceof TestCaseTimeoutError) { state.interruptedByTimeout = true; + timedOut = true; } await runOnTestFailed(lifecycleState); - await runOnTestFinished(lifecycleState); + await runTestFinishedOnce(); const testError = await getTestExecutionError( error, From 4525ceb58def85c6ba3d318e550638e598b2eacb Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 2 Jun 2026 21:39:15 +0200 Subject: [PATCH 04/11] Show pending promises on test timeout --- packages/bridge/src/shared/test-runner.ts | 10 +++ .../jest/src/__tests__/execute-run.test.ts | 59 +++++++++++++++ .../__tests__/format-harness-error.test.ts | 32 ++++++++ packages/jest/src/execute-run.ts | 9 ++- packages/jest/src/format-harness-error.ts | 73 +++++++++++++++++++ packages/jest/src/run.ts | 9 ++- .../src/__tests__/runner-context.test.ts | 12 +++ packages/runtime/src/runner/errors.ts | 7 ++ packages/runtime/src/runner/runSuite.ts | 41 ++++++++++- 9 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 packages/jest/src/__tests__/format-harness-error.test.ts create mode 100644 packages/jest/src/format-harness-error.ts diff --git a/packages/bridge/src/shared/test-runner.ts b/packages/bridge/src/shared/test-runner.ts index 3401f801..4ba4c060 100644 --- a/packages/bridge/src/shared/test-runner.ts +++ b/packages/bridge/src/shared/test-runner.ts @@ -15,6 +15,16 @@ export type SerializedError = { name: string; message: string; codeFrame?: CodeFrame; + diagnostics?: { + pendingPromises?: { + total: number; + items: Array<{ + id: number; + createdAt: number; + stack?: string; + }>; + }; + }; }; export type TestRunnerFileStartedEvent = { diff --git a/packages/jest/src/__tests__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 265aead1..9dccdfd9 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -330,6 +330,65 @@ describe('executeRun', () => { ['test-file-success', expect.anything(), expect.anything()], ]); }); + + it('includes pending promise diagnostics in live test-case failures', async () => { + let testRunnerListener: + | ((event: TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent) => void) + | undefined; + const { emitEvent, calls: emittedEvents } = makeEmitEvent(); + const session = makeSession({ + onTestRunnerEvent: vi.fn((listener) => { + testRunnerListener = listener as typeof testRunnerListener; + return () => undefined; + }), + }); + + mockRunHarnessTestFile.mockImplementation(async () => { + testRunnerListener?.({ + type: 'test-finished', + file: 'example.ts', + suite: 'suite', + name: 'hangs', + ancestorTitles: ['suite'], + fullName: 'suite hangs', + startedAt: 100, + duration: 50, + status: 'failed', + error: { + name: 'TestCaseTimeoutError', + message: 'Test timed out after 50ms: suite hangs', + diagnostics: { + pendingPromises: { + total: 1, + items: [ + { + id: 7, + createdAt: 110, + stack: 'Error: Promise created\n at hangs (example.ts:10:5)', + }, + ], + }, + }, + }, + }); + + return makeFileRunResult(); + }); + + await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig()); + + expect(emittedEvents).toContainEqual([ + 'test-case-result', + 'example.ts', + expect.objectContaining({ + failureMessages: [ + expect.stringContaining( + 'Pending promises at timeout: 1\n\nPromise #7, created 10ms after test start:', + ), + ], + }), + ]); + }); }); describe('runtime failures', () => { diff --git a/packages/jest/src/__tests__/format-harness-error.test.ts b/packages/jest/src/__tests__/format-harness-error.test.ts new file mode 100644 index 00000000..e6fd8506 --- /dev/null +++ b/packages/jest/src/__tests__/format-harness-error.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { formatHarnessErrorMessage } from '../format-harness-error.js'; + +describe('formatHarnessErrorMessage', () => { + it('formats pending promise diagnostics for timeout errors', () => { + const message = formatHarnessErrorMessage( + { + name: 'TestCaseTimeoutError', + message: 'Test timed out after 50ms: suite hangs', + diagnostics: { + pendingPromises: { + total: 2, + items: [ + { + id: 7, + createdAt: 110, + stack: 'Error: Promise created\n at hangs (example.ts:10:5)', + }, + ], + }, + }, + }, + { testStartedAt: 100 }, + ); + + expect(message).toContain('Test timed out after 50ms: suite hangs'); + expect(message).toContain('Pending promises at timeout: 2'); + expect(message).toContain('Showing 1 of 2 pending promises.'); + expect(message).toContain('Promise #7, created 10ms after test start:'); + expect(message).toContain(' Error: Promise created'); + }); +}); diff --git a/packages/jest/src/execute-run.ts b/packages/jest/src/execute-run.ts index 73ac12f6..abce2144 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -28,6 +28,7 @@ import { createPlatformSkippedTestResult, shouldRunHarnessTestFile, } from './test-file-platform-filter.js'; +import { formatHarnessErrorMessage } from './format-harness-error.js'; type EmitTestEvent = ( eventName: Name, @@ -87,8 +88,10 @@ const emitHarnessTestFinished = async ( emitEvent: EmitTestEvent, event: TestRunnerTestFinishedEvent, ): Promise => { - const failureMessage = event.error?.message; const codeFrame = event.error?.codeFrame; + const failureMessage = formatHarnessErrorMessage(event.error, { + testStartedAt: event.startedAt, + }); const location = codeFrame?.location ? { column: codeFrame.location.column, line: codeFrame.location.row } : null; @@ -96,9 +99,7 @@ const emitHarnessTestFinished = async ( ancestorTitles: event.ancestorTitles, duration: event.duration, failureDetails: [], - failureMessages: failureMessage - ? [`${failureMessage}${codeFrame ? `\n\n${codeFrame.content}` : ''}`] - : [], + failureMessages: failureMessage ? [failureMessage] : [], fullName: event.fullName, location, numPassingAsserts: event.status === 'passed' ? 1 : 0, diff --git a/packages/jest/src/format-harness-error.ts b/packages/jest/src/format-harness-error.ts new file mode 100644 index 00000000..ca9c32ac --- /dev/null +++ b/packages/jest/src/format-harness-error.ts @@ -0,0 +1,73 @@ +import type { SerializedError } from '@react-native-harness/bridge'; + +const formatPendingPromiseStack = (stack: string): string => + stack + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + +const formatPendingPromises = ( + pendingPromises: NonNullable< + NonNullable['pendingPromises'] + >, + testStartedAt?: number, +): string | null => { + if (pendingPromises.total === 0) { + return null; + } + + const lines = [`Pending promises at timeout: ${pendingPromises.total}`]; + + if (pendingPromises.items.length < pendingPromises.total) { + lines.push( + `Showing ${pendingPromises.items.length} of ${pendingPromises.total} pending promises.`, + ); + } + + for (const promise of pendingPromises.items) { + const age = + testStartedAt !== undefined + ? `, created ${Math.max(0, promise.createdAt - testStartedAt)}ms after test start` + : ''; + + lines.push(''); + lines.push(`Promise #${promise.id}${age}:`); + + if (promise.stack) { + lines.push(formatPendingPromiseStack(promise.stack)); + } else { + lines.push(' '); + } + } + + return lines.join('\n'); +}; + +export const formatHarnessErrorMessage = ( + error: SerializedError | undefined, + options: { + testStartedAt?: number; + } = {}, +): string | undefined => { + if (!error) { + return undefined; + } + + const parts = [error.message]; + const pendingPromiseDetails = error.diagnostics?.pendingPromises + ? formatPendingPromises( + error.diagnostics.pendingPromises, + options.testStartedAt, + ) + : null; + + if (pendingPromiseDetails) { + parts.push(pendingPromiseDetails); + } + + if (error.codeFrame) { + parts.push(error.codeFrame.content); + } + + return parts.join('\n\n'); +}; diff --git a/packages/jest/src/run.ts b/packages/jest/src/run.ts index 9c05838c..7fdba43b 100644 --- a/packages/jest/src/run.ts +++ b/packages/jest/src/run.ts @@ -8,6 +8,7 @@ import type { import type { HarnessSession } from './harness-session.js'; import { formatResultsErrors } from 'jest-message-util'; import { toTestResult } from './toTestResult.js'; +import { formatHarnessErrorMessage } from './format-harness-error.js'; // Helper function to flatten nested test suites into a flat array of tests with hierarchy const flattenTests = ( @@ -108,14 +109,14 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({ // Convert TestResult[] to the format expected by toTestResult const tests = allTests.map((test) => { - const errorMessage = test.error?.message; const codeFrame = test.error?.codeFrame; + const errorMessage = formatHarnessErrorMessage(test.error, { + testStartedAt: test.startedAt, + }); return { duration: test.duration, - errorMessage: errorMessage - ? `${errorMessage}${codeFrame ? `\n\n${codeFrame.content}` : ''}` - : undefined, + errorMessage, title: test.name, fullName: test.fullName, status: test.status, diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index 9a6960a3..dc175470 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -587,6 +587,8 @@ describe('runner task context', () => { }); it('fails the timed-out test and skips the rest of the file', async () => { + installPromiseTracker(); + const calls: string[] = []; const events: Array<{ type: string; name?: string; status?: string }> = []; const collector = getTestCollector(); @@ -638,6 +640,16 @@ describe('runner task context', () => { error: { name: 'TestCaseTimeoutError', message: expect.stringContaining('Timeout Suite hangs'), + diagnostics: { + pendingPromises: { + total: expect.any(Number), + items: expect.arrayContaining([ + expect.objectContaining({ + stack: expect.stringContaining('Promise created'), + }), + ]), + }, + }, }, }, { name: 'does not run', status: 'skipped' }, diff --git a/packages/runtime/src/runner/errors.ts b/packages/runtime/src/runner/errors.ts index 6eaf78b8..698366ab 100644 --- a/packages/runtime/src/runner/errors.ts +++ b/packages/runtime/src/runner/errors.ts @@ -29,11 +29,18 @@ export class TestExecutionError extends Error { const causeMessage = this.cause instanceof Error ? this.cause.message : 'Unknown message'; const causeCodeFrame = this.codeFrame; + const causeDiagnostics = + this.cause instanceof Error && + 'diagnostics' in this.cause && + typeof this.cause.diagnostics === 'object' + ? (this.cause.diagnostics as SerializedError['diagnostics']) + : undefined; return { name: causeName, message: causeMessage, codeFrame: causeCodeFrame, + diagnostics: causeDiagnostics, }; } } diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index e25122f1..03b4a6f7 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -14,7 +14,9 @@ import { runHooks } from './hooks.js'; import { getTestExecutionError } from './errors.js'; import { ActiveTestContext, TestRunnerContext } from './types.js'; import { + getPendingPromises, runWithoutPromiseTracking, + type TrackedPromiseRecord, withPromiseTrackerTestContext, } from '../promise-tracker.js'; import { @@ -47,14 +49,39 @@ const getFullName = (ancestorTitles: string[], testName: string): string => [...ancestorTitles, testName].join(' '); const DEFAULT_TEST_TIMEOUT_MS = 5_000; +const MAX_PENDING_PROMISE_DIAGNOSTICS = 10; + +type PendingPromiseDiagnostics = { + total: number; + items: Array<{ + id: number; + createdAt: number; + stack?: string; + }>; +}; + +const getPendingPromiseDiagnostics = ( + promises: TrackedPromiseRecord[], +): PendingPromiseDiagnostics => ({ + total: promises.length, + items: promises + .slice(0, MAX_PENDING_PROMISE_DIAGNOSTICS) + .map(({ id, createdAt, stack }) => ({ id, createdAt, stack })), +}); export class TestCaseTimeoutError extends Error { + diagnostics?: { + pendingPromises?: PendingPromiseDiagnostics; + }; + constructor( public readonly testName: string, public readonly timeout: number, + diagnostics?: TestCaseTimeoutError['diagnostics'], ) { super(`Test timed out after ${timeout}ms: ${testName}`); this.name = 'TestCaseTimeoutError'; + this.diagnostics = diagnostics; } } @@ -72,6 +99,7 @@ const getTestTimeout = (context: TestRunnerContext): number => { const withTestTimeout = async ( work: () => Promise, options: { + file: string; fullName: string; timeout: number; }, @@ -82,7 +110,17 @@ const withTestTimeout = async ( () => new Promise((_, reject) => { timeoutId = setTimeout(() => { - reject(new TestCaseTimeoutError(options.fullName, options.timeout)); + const pendingPromises = getPendingPromises().filter( + (promise) => + promise.test?.file === options.file && + promise.test.fullName === options.fullName, + ); + + reject( + new TestCaseTimeoutError(options.fullName, options.timeout, { + pendingPromises: getPendingPromiseDiagnostics(pendingPromises), + }), + ); }, options.timeout); }), ); @@ -353,6 +391,7 @@ const runTest = async ( } }, { + file: context.testFilePath, fullName, timeout: getTestTimeout(context), }, From 809d84920d19c74b494f088b90dbf6c32c4c6791 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 3 Jun 2026 07:28:37 +0200 Subject: [PATCH 05/11] docs: clarify bridge timeout semantics Add release planning notes for runtime test timeout reporting in PR 138. --- .changeset/runtime-test-timeouts.md | 7 +++++++ website/src/docs/getting-started/configuration.mdx | 8 +++++--- 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 .changeset/runtime-test-timeouts.md diff --git a/.changeset/runtime-test-timeouts.md b/.changeset/runtime-test-timeouts.md new file mode 100644 index 00000000..86692f24 --- /dev/null +++ b/.changeset/runtime-test-timeouts.md @@ -0,0 +1,7 @@ +--- +'@react-native-harness/bridge': patch +'@react-native-harness/jest': patch +'@react-native-harness/runtime': patch +--- + +Report stalled runtime test cases through per-test timeouts instead of letting the whole `runTests` bridge RPC fail generically. Harness now leaves `runTests` guarded by bridge heartbeat traffic, forwards the configured Jest test timeout into the runtime, marks the timed-out test as failed, skips the remaining tests in the file, restarts the app before continuing, and includes pending promise diagnostics in timeout failures. diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index f2003098..4de0c12f 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -212,7 +212,9 @@ Increase this value if you experience startup failures while: ## Bridge Timeout -The bridge timeout controls how long React Native Harness waits for the app to report runtime readiness after it has been launched. It does not include simulator or emulator boot time. +The bridge timeout controls the app-side bridge readiness window after Harness launches the app. It does not include simulator or emulator boot time, and it no longer limits the full `runTests` call for a test file. Once the runtime is connected, Harness keeps long-running test execution alive through heartbeat traffic and applies Jest's configured test timeout to each individual test case. + +Harness still uses `bridgeTimeout` while waiting for the app runtime to connect, and as the timeout for shorter bridge RPCs such as screenshots or image snapshot matching. ```javascript { @@ -223,10 +225,10 @@ The bridge timeout controls how long React Native Harness waits for the app to r **Default:** 60000 (60 seconds) **Minimum:** 1000 (1 second) -Increase this value if you experience timeout errors, especially on: +Increase this value if Harness times out before the app runtime reports ready, especially on: -- Complex test suites with heavy setup - Slower app startup after launch +- Apps that take longer to load the Metro bundle and initialize the Harness runtime ## Bundle Start Timeout From 83b03d8833ed211c697487adb3b8799a8b580109 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 3 Jun 2026 07:31:08 +0200 Subject: [PATCH 06/11] docs: describe bridge timeout current behavior Remove historical behavior framing from bridgeTimeout documentation. --- website/src/docs/getting-started/configuration.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 4de0c12f..c06bcf5d 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -212,9 +212,9 @@ Increase this value if you experience startup failures while: ## Bridge Timeout -The bridge timeout controls the app-side bridge readiness window after Harness launches the app. It does not include simulator or emulator boot time, and it no longer limits the full `runTests` call for a test file. Once the runtime is connected, Harness keeps long-running test execution alive through heartbeat traffic and applies Jest's configured test timeout to each individual test case. +The bridge timeout controls the app-side bridge readiness window after Harness launches the app. It does not include simulator or emulator boot time. Once the runtime is connected, Harness keeps test execution alive through heartbeat traffic and applies Jest's configured test timeout to each individual test case. -Harness still uses `bridgeTimeout` while waiting for the app runtime to connect, and as the timeout for shorter bridge RPCs such as screenshots or image snapshot matching. +Harness uses `bridgeTimeout` while waiting for the app runtime to connect, and as the timeout for shorter bridge RPCs such as screenshots or image snapshot matching. ```javascript { From beb5f13aa3c090034918489d570e3030c7e975fd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 3 Jun 2026 07:46:14 +0200 Subject: [PATCH 07/11] fix(runtime): time out suite hooks Extend runtime timeout reporting to beforeAll and afterAll hooks, surface suite-level timeout errors in Jest results, and restart the app after any runtime timeout. --- .../jest/src/__tests__/execute-run.test.ts | 36 ++++ packages/jest/src/execute-run.ts | 20 +- packages/jest/src/run.ts | 11 + .../src/__tests__/promise-tracker.test.ts | 4 +- .../src/__tests__/runner-context.test.ts | 122 +++++++++++ packages/runtime/src/promise-tracker.ts | 1 + packages/runtime/src/runner/runSuite.ts | 192 ++++++++++++++++-- 7 files changed, 361 insertions(+), 25 deletions(-) diff --git a/packages/jest/src/__tests__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 9dccdfd9..3fe8bfae 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -610,6 +610,42 @@ describe('executeRun', () => { expect(session.restartApp).toHaveBeenCalledTimes(1); expect(session.restartApp).toHaveBeenCalledWith('/a.ts'); }); + + it('restarts after a suite hook timeout', async () => { + const timedOutResult = makeHarnessResult('failed'); + timedOutResult.suites = [ + { + name: 'suite', + tests: [], + suites: [], + status: 'failed', + duration: 10, + error: { + name: 'SuiteHookTimeoutError', + message: 'beforeAll hook timed out after 10ms in suite: suite', + }, + }, + ]; + mockRunHarnessTestFile.mockResolvedValueOnce(makeFileRunResult({ + harnessResult: timedOutResult, + jestResult: makeJestResult({ + numFailingTests: 1, + numPassingTests: 0, + }), + })); + const session = makeSession(); + + await executeRun( + session, + [makeTest('/a.ts')], + makeWatcher(), + makeEmitEvent().emitEvent, + makeGlobalConfig(), + ); + + expect(session.restartApp).toHaveBeenCalledTimes(1); + expect(session.restartApp).toHaveBeenCalledWith('/a.ts'); + }); }); describe('platform-specific test files', () => { diff --git a/packages/jest/src/execute-run.ts b/packages/jest/src/execute-run.ts index abce2144..0ac3c56a 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -116,9 +116,17 @@ const isHarnessCaseEvent = ( ): event is TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent => event.type === 'test-started' || event.type === 'test-finished'; -const hasTestCaseTimeout = (result: TestSuiteResult): boolean => - result.tests.some((test) => test.error?.name === 'TestCaseTimeoutError') || - result.suites.some(hasTestCaseTimeout); +const TIMEOUT_ERROR_NAMES = new Set([ + 'SuiteHookTimeoutError', + 'TestCaseTimeoutError', +]); + +const hasRuntimeTimeout = (result: TestSuiteResult): boolean => + (result.error ? TIMEOUT_ERROR_NAMES.has(result.error.name) : false) || + result.tests.some((test) => + test.error ? TIMEOUT_ERROR_NAMES.has(test.error.name) : false, + ) || + result.suites.some(hasRuntimeTimeout); export const executeRun = async ( session: HarnessSession, @@ -267,11 +275,11 @@ export const executeRun = async ( duration: result.duration, result: result.harnessResult, }); - const didTestCaseTimeout = hasTestCaseTimeout(result.harnessResult); - shouldRestartAfterTimeout = didTestCaseTimeout; + const didRuntimeTimeout = hasRuntimeTimeout(result.harnessResult); + shouldRestartAfterTimeout = didRuntimeTimeout; await caseEventChain; await emitEvent('test-file-success', test, result.jestResult); - if (didTestCaseTimeout) { + if (didRuntimeTimeout) { await session.restartApp(test.path); shouldRestartAfterTimeout = false; } diff --git a/packages/jest/src/run.ts b/packages/jest/src/run.ts index 7fdba43b..87a5f1ba 100644 --- a/packages/jest/src/run.ts +++ b/packages/jest/src/run.ts @@ -17,6 +17,17 @@ const flattenTests = ( ): Array => { const tests: Array = []; + if (suiteResult.error) { + tests.push({ + name: suiteResult.name, + status: 'failed', + duration: suiteResult.duration, + error: suiteResult.error, + ancestorTitles: [...ancestorTitles], + fullName: [...ancestorTitles, suiteResult.name].join(' '), + }); + } + // Add tests from current suite with current hierarchy for (const test of suiteResult.tests) { tests.push({ diff --git a/packages/runtime/src/__tests__/promise-tracker.test.ts b/packages/runtime/src/__tests__/promise-tracker.test.ts index d0c0c88b..8a6c4980 100644 --- a/packages/runtime/src/__tests__/promise-tracker.test.ts +++ b/packages/runtime/src/__tests__/promise-tracker.test.ts @@ -70,19 +70,21 @@ describe('promise tracker', () => { suite: 'Example suite', name: 'waits forever', fullName: 'Example suite waits forever', + phase: 'test', }, async () => { void new Promise(() => undefined); } ); - expect(getPendingPromises()).toEqual([ + expect(getPendingPromises().filter((promise) => promise.test)).toEqual([ expect.objectContaining({ test: { file: 'example.harness.ts', suite: 'Example suite', name: 'waits forever', fullName: 'Example suite waits forever', + phase: 'test', }, }), ]); diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index dc175470..676f0ff7 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -1,5 +1,7 @@ import { afterEach, + afterAll as harnessAfterAll, + beforeAll as harnessBeforeAll, beforeEach, describe as harnessDescribe, getTestCollector, @@ -145,6 +147,7 @@ describe('runner task context', () => { suite: 'Promise Suite', name: 'leaves promise pending', fullName: 'Promise Suite leaves promise pending', + phase: 'test', }, }), ]); @@ -673,6 +676,125 @@ describe('runner task context', () => { } }); + it('fails the suite when beforeAll times out and skips its tests', async () => { + installPromiseTracker(); + + const calls: string[] = []; + const events: Array<{ type: string; name?: string; status?: string }> = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + runner.events.addListener((event) => { + if (event.type === 'suite-finished') { + events.push({ + type: event.type, + name: event.name, + status: event.status, + }); + } + }); + + try { + const collection = await collector.collect(() => { + harnessDescribe('BeforeAll Timeout Suite', () => { + harnessBeforeAll(async () => { + calls.push('beforeAll'); + await new Promise(() => undefined); + }); + + harnessIt('does not run', () => { + calls.push('test'); + }); + }); + }, 'runtime/before-all-timeout.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/before-all-timeout.test.ts', + runner: 'ios', + testTimeout: 10, + }); + + expect(result.status).toBe('failed'); + expect(result.suites[0]).toMatchObject({ + name: 'BeforeAll Timeout Suite', + status: 'failed', + error: { + name: 'SuiteHookTimeoutError', + message: expect.stringContaining( + 'beforeAll hook timed out after 10ms in suite: BeforeAll Timeout Suite', + ), + diagnostics: { + pendingPromises: { + total: expect.any(Number), + items: expect.arrayContaining([ + expect.objectContaining({ + stack: expect.stringContaining('Promise created'), + }), + ]), + }, + }, + }, + tests: [{ name: 'does not run', status: 'skipped' }], + }); + expect(calls).toEqual(['beforeAll']); + expect(events).toContainEqual({ + type: 'suite-finished', + name: 'BeforeAll Timeout Suite', + status: 'failed', + }); + } finally { + collector.dispose(); + runner.dispose(); + } + }); + + it('fails the suite when afterAll times out after tests pass', async () => { + installPromiseTracker(); + + const calls: string[] = []; + const collector = getTestCollector(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('AfterAll Timeout Suite', () => { + harnessIt('runs first', () => { + calls.push('test'); + }); + + harnessAfterAll(async () => { + calls.push('afterAll'); + await new Promise(() => undefined); + }); + }); + }, 'runtime/after-all-timeout.test.ts'); + + const result = await runner.run({ + testSuite: collection.testSuite, + testFilePath: 'runtime/after-all-timeout.test.ts', + runner: 'ios', + testTimeout: 10, + }); + + expect(result.status).toBe('failed'); + expect(result.suites[0]).toMatchObject({ + name: 'AfterAll Timeout Suite', + status: 'failed', + error: { + name: 'SuiteHookTimeoutError', + message: expect.stringContaining( + 'afterAll hook timed out after 10ms in suite: AfterAll Timeout Suite', + ), + }, + tests: [{ name: 'runs first', status: 'passed' }], + }); + expect(calls).toEqual(['test', 'afterAll']); + } finally { + collector.dispose(); + runner.dispose(); + } + }); + it('runs onTestFinished only once when a timed-out test eventually resumes', async () => { const calls: string[] = []; const collector = getTestCollector(); diff --git a/packages/runtime/src/promise-tracker.ts b/packages/runtime/src/promise-tracker.ts index ce7a0b00..c8ba4da8 100644 --- a/packages/runtime/src/promise-tracker.ts +++ b/packages/runtime/src/promise-tracker.ts @@ -3,6 +3,7 @@ export type PromiseTrackerTestContext = { suite: string; name: string; fullName: string; + phase?: 'beforeAll' | 'beforeEach' | 'test' | 'afterEach' | 'afterAll'; }; export type TrackedPromiseRecord = { diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index 03b4a6f7..6a8f22c4 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -10,7 +10,7 @@ import { type HarnessExpectTestState, } from '../expect/context.js'; import { flushExpectTestState } from '../expect/errors.js'; -import { runHooks } from './hooks.js'; +import { runHooks, type HookType } from './hooks.js'; import { getTestExecutionError } from './errors.js'; import { ActiveTestContext, TestRunnerContext } from './types.js'; import { @@ -85,6 +85,23 @@ export class TestCaseTimeoutError extends Error { } } +export class SuiteHookTimeoutError extends Error { + diagnostics?: { + pendingPromises?: PendingPromiseDiagnostics; + }; + + constructor( + public readonly hookType: Extract, + public readonly suiteName: string, + public readonly timeout: number, + diagnostics?: SuiteHookTimeoutError['diagnostics'], + ) { + super(`${hookType} hook timed out after ${timeout}ms in suite: ${suiteName}`); + this.name = 'SuiteHookTimeoutError'; + this.diagnostics = diagnostics; + } +} + type RunSuiteState = { interruptedByTimeout: boolean; }; @@ -96,11 +113,10 @@ const getTestTimeout = (context: TestRunnerContext): number => { : DEFAULT_TEST_TIMEOUT_MS; }; -const withTestTimeout = async ( +const withRuntimeTimeout = async ( work: () => Promise, options: { - file: string; - fullName: string; + createTimeoutError: () => Error; timeout: number; }, ): Promise => { @@ -110,17 +126,7 @@ const withTestTimeout = async ( () => new Promise((_, reject) => { timeoutId = setTimeout(() => { - const pendingPromises = getPendingPromises().filter( - (promise) => - promise.test?.file === options.file && - promise.test.fullName === options.fullName, - ); - - reject( - new TestCaseTimeoutError(options.fullName, options.timeout, { - pendingPromises: getPendingPromiseDiagnostics(pendingPromises), - }), - ); + reject(options.createTimeoutError()); }, options.timeout); }), ); @@ -137,6 +143,79 @@ const withTestTimeout = async ( } }; +const withTestTimeout = async ( + work: () => Promise, + options: { + file: string; + fullName: string; + timeout: number; + }, +): Promise => { + const getMatchingPendingPromises = () => + getPendingPromises().filter( + (promise) => + promise.test?.file === options.file && + promise.test.fullName === options.fullName && + promise.test.phase === 'test', + ); + + return await withRuntimeTimeout(work, { + timeout: options.timeout, + createTimeoutError: () => + new TestCaseTimeoutError(options.fullName, options.timeout, { + pendingPromises: getPendingPromiseDiagnostics( + getMatchingPendingPromises(), + ), + }), + }); +}; + +const withSuiteHookTimeout = async ( + work: () => Promise, + options: { + file: string; + hookType: Extract; + suiteName: string; + timeout: number; + }, +): Promise => { + const getMatchingPendingPromises = () => + getPendingPromises().filter( + (promise) => + promise.test?.file === options.file && + promise.test.suite === options.suiteName && + promise.test.phase === options.hookType, + ); + + return await withRuntimeTimeout( + () => + withPromiseTrackerTestContext( + { + file: options.file, + suite: options.suiteName, + name: options.hookType, + fullName: `${options.suiteName} ${options.hookType}`, + phase: options.hookType, + }, + work, + ), + { + timeout: options.timeout, + createTimeoutError: () => + new SuiteHookTimeoutError( + options.hookType, + options.suiteName, + options.timeout, + { + pendingPromises: getPendingPromiseDiagnostics( + getMatchingPendingPromises(), + ), + }, + ), + }, + ); +}; + const emitTestFinished = ( context: TestRunnerContext, options: { @@ -362,6 +441,7 @@ const runTest = async ( suite: suite.name, name: test.name, fullName, + phase: 'test', }, async () => { try { @@ -561,7 +641,56 @@ export const runSuite = async ( const suiteResults: TestSuiteResult[] = []; // Run beforeAll hooks - await runHooks(suite, 'beforeAll'); + try { + await withSuiteHookTimeout( + () => runHooks(suite, 'beforeAll'), + { + file: context.testFilePath, + hookType: 'beforeAll', + suiteName: suite.name, + timeout: getTestTimeout(context), + }, + ); + } catch (error) { + if (!(error instanceof SuiteHookTimeoutError)) { + throw error; + } + + state.interruptedByTimeout = true; + const duration = Date.now() - startTime; + const suiteError = ( + await getTestExecutionError( + error, + context.testFilePath, + suite.name, + 'beforeAll', + ) + ).toSerializedJSON(); + const skippedTests = suite.tests.map((test) => + createSkippedTestResult(test, suite, context), + ); + const skippedSuites = suite.suites.map((childSuite) => + createSkippedSuiteResult(childSuite, context), + ); + + context.events.emit({ + type: 'suite-finished', + file: context.testFilePath, + name: suite.name, + duration, + error: suiteError, + status: 'failed', + }); + + return { + name: suite.name, + tests: skippedTests, + suites: skippedSuites, + status: 'failed', + error: suiteError, + duration, + }; + } // Run all tests in the current suite for (const test of suite.tests) { @@ -580,8 +709,33 @@ export const runSuite = async ( } // Run afterAll hooks + let suiteError: TestSuiteResult['error']; if (!state.interruptedByTimeout) { - await runHooks(suite, 'afterAll'); + try { + await withSuiteHookTimeout( + () => runHooks(suite, 'afterAll'), + { + file: context.testFilePath, + hookType: 'afterAll', + suiteName: suite.name, + timeout: getTestTimeout(context), + }, + ); + } catch (error) { + if (!(error instanceof SuiteHookTimeoutError)) { + throw error; + } + + state.interruptedByTimeout = true; + suiteError = ( + await getTestExecutionError( + error, + context.testFilePath, + suite.name, + 'afterAll', + ) + ).toSerializedJSON(); + } } const duration = Date.now() - startTime; @@ -597,7 +751,7 @@ export const runSuite = async ( (result) => result.status === 'failed' ); - if (hasFailedTests || hasFailedSuites) { + if (suiteError || hasFailedTests || hasFailedSuites) { status = 'failed'; } else { // Check if all tests and suites are skipped (and there are some tests/suites to check) @@ -632,6 +786,7 @@ export const runSuite = async ( file: context.testFilePath, name: suite.name, duration, + error: suiteError, status, }); @@ -640,6 +795,7 @@ export const runSuite = async ( tests: testResults, suites: suiteResults, status, + error: suiteError, duration, }; }; From 3b7a6a385a17fad1f83a13f4c24bc58eda4a8bc9 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 3 Jun 2026 08:07:35 +0200 Subject: [PATCH 08/11] feat(config): add test timeout option Add a Harness-level testTimeout config value and use it as the runtime timeout fallback when Jest does not provide one. Document the option and cover the precedence behavior in Jest package tests. --- .changeset/runtime-test-timeouts.md | 3 +- actions/shared/index.cjs | 1 + .../jest-platform-ignore-pattern.test.ts | 1 + .../src/__tests__/platform-commands.test.ts | 4 + packages/config/src/types.ts | 5 ++ .../jest/src/__tests__/metro-port.test.ts | 1 + packages/jest/src/__tests__/run.test.ts | 74 +++++++++++++++++++ packages/jest/src/run.ts | 3 +- .../docs/getting-started/configuration.mdx | 19 ++++- 9 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 packages/jest/src/__tests__/run.test.ts diff --git a/.changeset/runtime-test-timeouts.md b/.changeset/runtime-test-timeouts.md index 86692f24..115214a8 100644 --- a/.changeset/runtime-test-timeouts.md +++ b/.changeset/runtime-test-timeouts.md @@ -1,7 +1,8 @@ --- '@react-native-harness/bridge': patch +'@react-native-harness/config': patch '@react-native-harness/jest': patch '@react-native-harness/runtime': patch --- -Report stalled runtime test cases through per-test timeouts instead of letting the whole `runTests` bridge RPC fail generically. Harness now leaves `runTests` guarded by bridge heartbeat traffic, forwards the configured Jest test timeout into the runtime, marks the timed-out test as failed, skips the remaining tests in the file, restarts the app before continuing, and includes pending promise diagnostics in timeout failures. +Report stalled runtime test cases through per-test timeouts instead of letting the whole `runTests` bridge RPC fail generically. Harness now leaves `runTests` guarded by bridge heartbeat traffic, forwards the configured Jest or Harness test timeout into the runtime, marks the timed-out test as failed, skips the remaining tests in the file, restarts the app before continuing, and includes pending promise diagnostics in timeout failures. diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index 826790c6..0264f9d1 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -4417,6 +4417,7 @@ var ConfigSchema = external_exports.object({ metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT), webSocketPort: external_exports.number().optional().describe("Deprecated. Bridge traffic now uses metroPort and this value is ignored."), bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4), + testTimeout: external_exports.number().min(1e3, "Test timeout must be at least 1 second").default(5e3), platformReadyTimeout: external_exports.number().min(1e3, "Platform ready timeout must be at least 1 second").default(3e5), bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4), maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2), diff --git a/packages/cli/src/__tests__/jest-platform-ignore-pattern.test.ts b/packages/cli/src/__tests__/jest-platform-ignore-pattern.test.ts index 2d4793b6..4242d29a 100644 --- a/packages/cli/src/__tests__/jest-platform-ignore-pattern.test.ts +++ b/packages/cli/src/__tests__/jest-platform-ignore-pattern.test.ts @@ -33,6 +33,7 @@ const makeConfig = (): Config => ({ metroPort: 8081, webSocketPort: undefined, bridgeTimeout: 60000, + testTimeout: 5000, platformReadyTimeout: 300000, bundleStartTimeout: 60000, maxAppRestarts: 2, diff --git a/packages/cli/src/__tests__/platform-commands.test.ts b/packages/cli/src/__tests__/platform-commands.test.ts index 16dc2663..49cd7cd8 100644 --- a/packages/cli/src/__tests__/platform-commands.test.ts +++ b/packages/cli/src/__tests__/platform-commands.test.ts @@ -44,6 +44,7 @@ describe('platform CLI command discovery', () => { metroPort: 8081, webSocketPort: undefined, bridgeTimeout: 60000, + testTimeout: 5000, platformReadyTimeout: 300000, bundleStartTimeout: 60000, maxAppRestarts: 2, @@ -106,6 +107,7 @@ describe('platform CLI command discovery', () => { metroPort: 8081, webSocketPort: undefined, bridgeTimeout: 60000, + testTimeout: 5000, platformReadyTimeout: 300000, bundleStartTimeout: 60000, maxAppRestarts: 2, @@ -146,6 +148,7 @@ describe('platform CLI command discovery', () => { metroPort: 8081, webSocketPort: undefined, bridgeTimeout: 60000, + testTimeout: 5000, platformReadyTimeout: 300000, bundleStartTimeout: 60000, maxAppRestarts: 2, @@ -208,6 +211,7 @@ describe('platform CLI command discovery', () => { metroPort: 8081, webSocketPort: undefined, bridgeTimeout: 60000, + testTimeout: 5000, platformReadyTimeout: 300000, bundleStartTimeout: 60000, maxAppRestarts: 2, diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index fc0d5143..25b939b0 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -53,6 +53,11 @@ export const ConfigSchema = z .min(1000, 'Bridge timeout must be at least 1 second') .default(60000), + testTimeout: z + .number() + .min(1000, 'Test timeout must be at least 1 second') + .default(5000), + platformReadyTimeout: z .number() .min(1000, 'Platform ready timeout must be at least 1 second') diff --git a/packages/jest/src/__tests__/metro-port.test.ts b/packages/jest/src/__tests__/metro-port.test.ts index bbf7b7f6..6c1e5731 100644 --- a/packages/jest/src/__tests__/metro-port.test.ts +++ b/packages/jest/src/__tests__/metro-port.test.ts @@ -27,6 +27,7 @@ const createConfig = (overrides: Partial = {}): HarnessConfig => platformReadyTimeout: 300_000, resetEnvironmentBetweenTestFiles: true, runners: [], + testTimeout: 5_000, unstable__enableMetroCache: false, unstable__skipAlreadyIncludedModules: false, ...overrides, diff --git a/packages/jest/src/__tests__/run.test.ts b/packages/jest/src/__tests__/run.test.ts new file mode 100644 index 00000000..18aac0b2 --- /dev/null +++ b/packages/jest/src/__tests__/run.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Config as JestConfig } from 'jest-runner'; +import type { TestSuiteResult } from '@react-native-harness/bridge'; +import type { HarnessSession } from '../harness-session.js'; +import { runHarnessTestFile } from '../run.js'; + +vi.mock('jest-message-util', () => ({ + formatResultsErrors: vi.fn(() => ''), +})); + +const createHarnessResult = (): TestSuiteResult => ({ + name: 'root', + tests: [], + suites: [], + status: 'passed', + duration: 0, +}); + +const createSession = (testTimeout: number): HarnessSession => + ({ + config: { testTimeout }, + context: { + platform: { + runner: './runner.js', + }, + }, + runTestFile: vi.fn(async () => createHarnessResult()), + }) as unknown as HarnessSession; + +const createGlobalConfig = ( + overrides: Partial = {}, +): JestConfig.GlobalConfig => + ({ + rootDir: '/project', + ...overrides, + }) as JestConfig.GlobalConfig; + +const createProjectConfig = ( + overrides: Partial = {}, +): JestConfig.ProjectConfig => overrides as JestConfig.ProjectConfig; + +describe('runHarnessTestFile', () => { + it('uses Harness config testTimeout when Jest config does not set one', async () => { + const session = createSession(15000); + + await runHarnessTestFile({ + testPath: '/project/example.harness.ts', + session, + globalConfig: createGlobalConfig(), + projectConfig: createProjectConfig(), + }); + + expect(session.runTestFile).toHaveBeenCalledWith( + 'example.harness.ts', + expect.objectContaining({ testTimeout: 15000 }), + ); + }); + + it('keeps Jest project testTimeout above Harness config testTimeout', async () => { + const session = createSession(15000); + + await runHarnessTestFile({ + testPath: '/project/example.harness.ts', + session, + globalConfig: createGlobalConfig({ testTimeout: 20000 } as never), + projectConfig: createProjectConfig({ testTimeout: 30000 } as never), + }); + + expect(session.runTestFile).toHaveBeenCalledWith( + 'example.harness.ts', + expect.objectContaining({ testTimeout: 30000 }), + ); + }); +}); diff --git a/packages/jest/src/run.ts b/packages/jest/src/run.ts index 87a5f1ba..2aff7f6d 100644 --- a/packages/jest/src/run.ts +++ b/packages/jest/src/run.ts @@ -104,7 +104,8 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({ (projectConfig as JestConfig.ProjectConfig & { testTimeout?: number }) .testTimeout ?? (globalConfig as JestConfig.GlobalConfig & { testTimeout?: number }) - .testTimeout; + .testTimeout ?? + session.config.testTimeout; const harnessResult = await session.runTestFile(relativeTestPath, { testNamePattern: globalConfig.testNamePattern, diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index c06bcf5d..bc6d2329 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -98,6 +98,7 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` | `webSocketPort` | Deprecated. Bridge traffic now uses `metroPort`; this option is ignored. | | `platformReadyTimeout` | Platform-ready timeout in milliseconds (default: `300000`). | | `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | +| `testTimeout` | Runtime timeout for each test case and suite hook in milliseconds (default: `5000`). Jest `testTimeout` takes precedence when configured. | | `bundleStartTimeout` | Bundle start timeout in milliseconds (default: `60000`). | | `maxAppRestarts` | Maximum number of automatic app relaunch attempts while Harness is waiting for startup (default: `2`). | | `permissions` | Enable platform-specific permission prompt automation (default: `false`). On iOS, this controls whether Harness starts the XCTest-based permission helper. | @@ -212,7 +213,7 @@ Increase this value if you experience startup failures while: ## Bridge Timeout -The bridge timeout controls the app-side bridge readiness window after Harness launches the app. It does not include simulator or emulator boot time. Once the runtime is connected, Harness keeps test execution alive through heartbeat traffic and applies Jest's configured test timeout to each individual test case. +The bridge timeout controls the app-side bridge readiness window after Harness launches the app. It does not include simulator or emulator boot time. Once the runtime is connected, Harness keeps test execution alive through heartbeat traffic and applies the configured test timeout to each individual test case. Harness uses `bridgeTimeout` while waiting for the app runtime to connect, and as the timeout for shorter bridge RPCs such as screenshots or image snapshot matching. @@ -230,6 +231,21 @@ Increase this value if Harness times out before the app runtime reports ready, e - Slower app startup after launch - Apps that take longer to load the Metro bundle and initialize the Harness runtime +## Test Timeout + +The test timeout controls how long the runtime allows each test case and suite hook to run before reporting a timeout failure. + +```javascript +{ + testTimeout: 30000, +} +``` + +**Default:** 5000 (5 seconds) +**Minimum:** 1000 (1 second) + +Jest's `testTimeout` setting takes precedence when it is configured for the project or run. Use Harness `testTimeout` when you want the timeout to live next to the rest of your Harness runtime configuration. + ## Bundle Start Timeout The bundle start timeout controls how long React Native Harness waits for the launched app to request its Metro bundle. @@ -322,6 +338,7 @@ const config = { platformReadyTimeout: isCI ? 420000 : 300000, bridgeTimeout: isCI ? 180000 : 60000, + testTimeout: isCI ? 30000 : 5000, }; export default config; From 48780920d2298b5d84ac1107dcc46925b9f32372 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 3 Jun 2026 08:09:50 +0200 Subject: [PATCH 09/11] fix(jest): prefer Harness test timeout Use Harness config testTimeout ahead of Jest timeout settings and update docs/tests to describe the precedence. --- .changeset/runtime-test-timeouts.md | 2 +- packages/jest/src/__tests__/run.test.ts | 4 ++-- packages/jest/src/run.ts | 4 ++-- website/src/docs/getting-started/configuration.mdx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/runtime-test-timeouts.md b/.changeset/runtime-test-timeouts.md index 115214a8..3264410f 100644 --- a/.changeset/runtime-test-timeouts.md +++ b/.changeset/runtime-test-timeouts.md @@ -5,4 +5,4 @@ '@react-native-harness/runtime': patch --- -Report stalled runtime test cases through per-test timeouts instead of letting the whole `runTests` bridge RPC fail generically. Harness now leaves `runTests` guarded by bridge heartbeat traffic, forwards the configured Jest or Harness test timeout into the runtime, marks the timed-out test as failed, skips the remaining tests in the file, restarts the app before continuing, and includes pending promise diagnostics in timeout failures. +Report stalled runtime test cases through per-test timeouts instead of letting the whole `runTests` bridge RPC fail generically. Harness now leaves `runTests` guarded by bridge heartbeat traffic, forwards the configured Harness test timeout into the runtime, marks the timed-out test as failed, skips the remaining tests in the file, restarts the app before continuing, and includes pending promise diagnostics in timeout failures. diff --git a/packages/jest/src/__tests__/run.test.ts b/packages/jest/src/__tests__/run.test.ts index 18aac0b2..9ee9df81 100644 --- a/packages/jest/src/__tests__/run.test.ts +++ b/packages/jest/src/__tests__/run.test.ts @@ -56,7 +56,7 @@ describe('runHarnessTestFile', () => { ); }); - it('keeps Jest project testTimeout above Harness config testTimeout', async () => { + it('keeps Harness config testTimeout above Jest config testTimeout', async () => { const session = createSession(15000); await runHarnessTestFile({ @@ -68,7 +68,7 @@ describe('runHarnessTestFile', () => { expect(session.runTestFile).toHaveBeenCalledWith( 'example.harness.ts', - expect.objectContaining({ testTimeout: 30000 }), + expect.objectContaining({ testTimeout: 15000 }), ); }); }); diff --git a/packages/jest/src/run.ts b/packages/jest/src/run.ts index 2aff7f6d..506b323b 100644 --- a/packages/jest/src/run.ts +++ b/packages/jest/src/run.ts @@ -101,11 +101,11 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({ (setupFile) => path.relative(globalConfig.rootDir, setupFile) ); const testTimeout = + session.config.testTimeout ?? (projectConfig as JestConfig.ProjectConfig & { testTimeout?: number }) .testTimeout ?? (globalConfig as JestConfig.GlobalConfig & { testTimeout?: number }) - .testTimeout ?? - session.config.testTimeout; + .testTimeout; const harnessResult = await session.runTestFile(relativeTestPath, { testNamePattern: globalConfig.testNamePattern, diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index bc6d2329..eee184e3 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -98,7 +98,7 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` | `webSocketPort` | Deprecated. Bridge traffic now uses `metroPort`; this option is ignored. | | `platformReadyTimeout` | Platform-ready timeout in milliseconds (default: `300000`). | | `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | -| `testTimeout` | Runtime timeout for each test case and suite hook in milliseconds (default: `5000`). Jest `testTimeout` takes precedence when configured. | +| `testTimeout` | Runtime timeout for each test case and suite hook in milliseconds (default: `5000`). Harness config takes precedence over Jest `testTimeout`. | | `bundleStartTimeout` | Bundle start timeout in milliseconds (default: `60000`). | | `maxAppRestarts` | Maximum number of automatic app relaunch attempts while Harness is waiting for startup (default: `2`). | | `permissions` | Enable platform-specific permission prompt automation (default: `false`). On iOS, this controls whether Harness starts the XCTest-based permission helper. | @@ -244,7 +244,7 @@ The test timeout controls how long the runtime allows each test case and suite h **Default:** 5000 (5 seconds) **Minimum:** 1000 (1 second) -Jest's `testTimeout` setting takes precedence when it is configured for the project or run. Use Harness `testTimeout` when you want the timeout to live next to the rest of your Harness runtime configuration. +Harness `testTimeout` takes precedence over Jest's `testTimeout` setting so runtime timeout behavior lives next to the rest of your Harness configuration. ## Bundle Start Timeout From 4ba2b6948574b87fa8f0bbc8ee7b77d5406eb13c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 3 Jun 2026 09:07:52 +0200 Subject: [PATCH 10/11] fix(runtime): propagate promise tracker context Propagate promise tracker test context through promise chains and omit Harness wrapper promises from timeout diagnostics. --- .../src/__tests__/promise-tracker.test.ts | 88 +++++++-- packages/runtime/src/promise-tracker.ts | 176 ++++++++++++++++-- packages/runtime/src/runner/hooks.ts | 18 +- packages/runtime/src/runner/runSuite.ts | 130 +++++++++---- 4 files changed, 342 insertions(+), 70 deletions(-) diff --git a/packages/runtime/src/__tests__/promise-tracker.test.ts b/packages/runtime/src/__tests__/promise-tracker.test.ts index 8a6c4980..3764397b 100644 --- a/packages/runtime/src/__tests__/promise-tracker.test.ts +++ b/packages/runtime/src/__tests__/promise-tracker.test.ts @@ -3,6 +3,7 @@ import { clearTrackedPromises, getPendingPromises, installPromiseTracker, + type PromiseTrackerTestContext, uninstallPromiseTracker, withPromiseTrackerTestContext, } from '../promise-tracker.js'; @@ -11,6 +12,14 @@ afterEach(() => { uninstallPromiseTracker(); }); +const testContext: PromiseTrackerTestContext = { + file: 'example.harness.ts', + suite: 'Example suite', + name: 'waits forever', + fullName: 'Example suite waits forever', + phase: 'test', +}; + describe('promise tracker', () => { it('tracks pending promises created through the global Promise constructor', () => { installPromiseTracker(); @@ -65,13 +74,7 @@ describe('promise tracker', () => { installPromiseTracker(); await withPromiseTrackerTestContext( - { - file: 'example.harness.ts', - suite: 'Example suite', - name: 'waits forever', - fullName: 'Example suite waits forever', - phase: 'test', - }, + testContext, async () => { void new Promise(() => undefined); } @@ -79,13 +82,70 @@ describe('promise tracker', () => { expect(getPendingPromises().filter((promise) => promise.test)).toEqual([ expect.objectContaining({ - test: { - file: 'example.harness.ts', - suite: 'Example suite', - name: 'waits forever', - fullName: 'Example suite waits forever', - phase: 'test', - }, + test: testContext, + }), + ]); + }); + + it('propagates test context to promises created in then callbacks', async () => { + installPromiseTracker(); + + let parent!: Promise; + + await withPromiseTrackerTestContext(testContext, async () => { + parent = Promise.resolve('ready'); + }); + + void parent.then(() => { + void new Promise(() => undefined); + }); + await Promise.resolve(); + + expect(getPendingPromises().filter((promise) => promise.test)).toEqual([ + expect.objectContaining({ + test: testContext, + }), + ]); + }); + + it('propagates test context to promises created in catch callbacks', async () => { + installPromiseTracker(); + + let parent!: Promise; + + await withPromiseTrackerTestContext(testContext, async () => { + parent = Promise.reject(new Error('failed')); + }); + + void parent.catch(() => { + void new Promise(() => undefined); + }); + await Promise.resolve(); + + expect(getPendingPromises().filter((promise) => promise.test)).toEqual([ + expect.objectContaining({ + test: testContext, + }), + ]); + }); + + it('propagates test context to promises created in finally callbacks', async () => { + installPromiseTracker(); + + let parent!: Promise; + + await withPromiseTrackerTestContext(testContext, async () => { + parent = Promise.resolve('ready'); + }); + + void parent.finally(() => { + void new Promise(() => undefined); + }); + await Promise.resolve(); + + expect(getPendingPromises().filter((promise) => promise.test)).toEqual([ + expect.objectContaining({ + test: testContext, }), ]); }); diff --git a/packages/runtime/src/promise-tracker.ts b/packages/runtime/src/promise-tracker.ts index c8ba4da8..b1e785f7 100644 --- a/packages/runtime/src/promise-tracker.ts +++ b/packages/runtime/src/promise-tracker.ts @@ -21,6 +21,8 @@ type PromiseExecutor = ( ) => void; const pendingPromises = new Map(); +const promiseIds = new WeakMap(); +const promiseContexts = new WeakMap(); let originalPromise: PromiseConstructor | null = null; let nextPromiseId = 1; @@ -38,21 +40,31 @@ const createPromiseStack = (): string | undefined => { } }; -const registerPromise = (): number | null => { +const cloneTestContext = ( + context: PromiseTrackerTestContext +): PromiseTrackerTestContext => ({ ...context }); + +const getCurrentPromiseContext = (): PromiseTrackerTestContext | undefined => + currentTestContext ? cloneTestContext(currentTestContext) : undefined; + +const registerPromise = (): + | { id: number; test?: PromiseTrackerTestContext } + | { id: null; test?: undefined } => { if (trackingDisabledDepth > 0) { - return null; + return { id: null }; } const id = nextPromiseId++; + const test = getCurrentPromiseContext(); pendingPromises.set(id, { id, createdAt: Date.now(), stack: createPromiseStack(), - test: currentTestContext, + test, }); - return id; + return { id, test }; }; const markPromiseSettled = (id: number | null) => { @@ -63,18 +75,61 @@ const markPromiseSettled = (id: number | null) => { pendingPromises.delete(id); }; +export const omitPromiseFromTracking = (promise: unknown): void => { + if (promise == null || typeof promise !== 'object') { + return; + } + + const id = promiseIds.get(promise); + + if (id === undefined) { + return; + } + + pendingPromises.delete(id); +}; + const isThenable = (value: T | PromiseLike): value is PromiseLike => value != null && typeof value === 'object' && 'then' in value && typeof value.then === 'function'; +const runWithPromiseTrackerTestContext = ( + context: PromiseTrackerTestContext | undefined, + work: () => T +): T => { + if (!context) { + return work(); + } + + const previousContext = currentTestContext; + currentTestContext = context; + + try { + return work(); + } finally { + currentTestContext = previousContext; + } +}; + +const wrapPromiseCallback = ( + context: PromiseTrackerTestContext | undefined, + callback: ((...args: TArgs) => TResult) | undefined | null +): ((...args: TArgs) => TResult) | undefined => { + if (callback == null) { + return undefined; + } + + return (...args) => runWithPromiseTrackerTestContext(context, () => callback(...args)); +}; + const createTrackedPromiseConstructor = (): PromiseConstructor => { const NativePromise = getOriginalPromise(); class TrackedPromise extends NativePromise { constructor(executor: PromiseExecutor) { - const id = registerPromise(); + const registration = registerPromise(); super((resolve, reject) => { try { @@ -83,26 +138,104 @@ const createTrackedPromiseConstructor = (): PromiseConstructor => { if (isThenable(value)) { runWithoutPromiseTracking(() => { NativePromise.resolve(value).then( - () => markPromiseSettled(id), - () => markPromiseSettled(id) + () => markPromiseSettled(registration.id), + () => markPromiseSettled(registration.id) ); }); } else { - markPromiseSettled(id); + markPromiseSettled(registration.id); } resolve(value); }, (reason?: unknown) => { - markPromiseSettled(id); + markPromiseSettled(registration.id); reject(reason); } ); } catch (error) { - markPromiseSettled(id); + markPromiseSettled(registration.id); throw error; } }); + + if (registration.id !== null) { + promiseIds.set(this, registration.id); + } + + if (registration.test) { + promiseContexts.set(this, registration.test); + } + } + + override then( + onfulfilled?: + | ((value: T) => TResult1 | PromiseLike) + | undefined + | null, + onrejected?: + | ((reason: unknown) => TResult2 | PromiseLike) + | undefined + | null + ): Promise { + const context = promiseContexts.get(this); + const result = runWithoutPromiseTracking(() => + super.then( + wrapPromiseCallback(context, onfulfilled), + wrapPromiseCallback(context, onrejected) + ) + ) as Promise; + + if (context && typeof result === 'object') { + promiseContexts.set(result, context); + } + + return result; + } + + override catch( + onrejected?: + | ((reason: unknown) => TResult | PromiseLike) + | undefined + | null + ): Promise { + const context = promiseContexts.get(this); + const result = runWithoutPromiseTracking(() => + super.catch(wrapPromiseCallback(context, onrejected)) + ) as Promise; + + if (context && typeof result === 'object') { + promiseContexts.set(result, context); + } + + return result; + } + + override finally(onfinally?: (() => void) | undefined | null): Promise { + const context = promiseContexts.get(this); + + if (onfinally == null) { + return this.then(); + } + + return this.then( + (value) => { + const result = runWithPromiseTrackerTestContext(context, onfinally); + + return runWithoutPromiseTracking(() => + NativePromise.resolve(result).then(() => value) + ); + }, + (reason: unknown) => { + const result = runWithPromiseTrackerTestContext(context, onfinally); + + return runWithoutPromiseTracking(() => + NativePromise.resolve(result).then(() => { + throw reason; + }) + ); + } + ); } } @@ -126,6 +259,7 @@ export const uninstallPromiseTracker = (): void => { globalThis.Promise = originalPromise; originalPromise = null; pendingPromises.clear(); + // WeakMap entries are released with their promises. currentTestContext = undefined; }; @@ -140,17 +274,31 @@ export const getPendingPromises = (): TrackedPromiseRecord[] => { })); }; -export const withPromiseTrackerTestContext = async ( +export const withPromiseTrackerTestContext = ( context: PromiseTrackerTestContext, - work: () => Promise + work: () => Promise, + options: { + omitReturnedPromise?: boolean; + } = {}, ): Promise => { const previousContext = currentTestContext; currentTestContext = context; try { - return await work(); - } finally { + const result = work(); + + if (options.omitReturnedPromise) { + omitPromiseFromTracking(result); + } + + return runWithoutPromiseTracking(() => + Promise.resolve(result).finally(() => { + currentTestContext = previousContext; + }), + ); + } catch (error) { currentTestContext = previousContext; + return runWithoutPromiseTracking(() => Promise.reject(error)); } }; diff --git a/packages/runtime/src/runner/hooks.ts b/packages/runtime/src/runner/hooks.ts index 69b47a6a..48fcd2f7 100644 --- a/packages/runtime/src/runner/hooks.ts +++ b/packages/runtime/src/runner/hooks.ts @@ -1,4 +1,5 @@ import type { SuiteHookFn, TestFn, TestSuite } from '@react-native-harness/bridge'; +import { omitPromiseFromTracking } from '../promise-tracker.js'; import type { ActiveTestContext } from './types.js'; export type HookType = 'beforeEach' | 'afterEach' | 'beforeAll' | 'afterAll'; @@ -56,12 +57,20 @@ export const runHooks = async ( suite: TestSuite, hookType: HookType, context?: ActiveTestContext, + options: { + wrapHook?: (runHook: () => Promise) => Promise; + } = {}, ): Promise => { if (hookType === 'beforeAll' || hookType === 'afterAll') { const hooks = collectSuiteHooks(suite, hookType); for (const hook of hooks) { - await hook(); + const runHook = async () => { + const result = hook(); + omitPromiseFromTracking(result); + await result; + }; + await (options.wrapHook ? options.wrapHook(runHook) : runHook()); } return; @@ -70,6 +79,11 @@ export const runHooks = async ( const hooks = collectInheritedHooks(suite, hookType); for (const hook of hooks) { - await hook(context as ActiveTestContext); + const runHook = async () => { + const result = hook(context as ActiveTestContext); + omitPromiseFromTracking(result); + await result; + }; + await (options.wrapHook ? options.wrapHook(runHook) : runHook()); } }; diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index 6a8f22c4..49c14c3f 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -15,6 +15,7 @@ import { getTestExecutionError } from './errors.js'; import { ActiveTestContext, TestRunnerContext } from './types.js'; import { getPendingPromises, + omitPromiseFromTracking, runWithoutPromiseTracking, type TrackedPromiseRecord, withPromiseTrackerTestContext, @@ -156,7 +157,9 @@ const withTestTimeout = async ( (promise) => promise.test?.file === options.file && promise.test.fullName === options.fullName && - promise.test.phase === 'test', + (promise.test.phase === 'beforeEach' || + promise.test.phase === 'test' || + promise.test.phase === 'afterEach'), ); return await withRuntimeTimeout(work, { @@ -188,17 +191,7 @@ const withSuiteHookTimeout = async ( ); return await withRuntimeTimeout( - () => - withPromiseTrackerTestContext( - { - file: options.file, - suite: options.suiteName, - name: options.hookType, - fullName: `${options.suiteName} ${options.hookType}`, - phase: options.hookType, - }, - work, - ), + work, { timeout: options.timeout, createTimeoutError: () => @@ -435,33 +428,62 @@ const runTest = async ( await withTestTimeout( async () => { - await withPromiseTrackerTestContext( - { - file: context.testFilePath, - suite: suite.name, - name: test.name, - fullName, - phase: 'test', - }, - async () => { - try { - // Run all beforeEach hooks from the current suite and its parents - await runHooks(suite, 'beforeEach', activeTestContext); - - // Run the actual test - await test.fn(activeTestContext); - } catch (error) { - if (!isSkipTestError(error)) { - throw error; - } - - didSkip = true; - } finally { - // Run all afterEach hooks from the current suite and its parents - await runHooks(suite, 'afterEach', activeTestContext); - } + try { + // Run all beforeEach hooks from the current suite and its parents + await runHooks(suite, 'beforeEach', activeTestContext, { + wrapHook: (runHook) => + withPromiseTrackerTestContext( + { + file: context.testFilePath, + suite: suite.name, + name: test.name, + fullName, + phase: 'beforeEach', + }, + runHook, + { omitReturnedPromise: true }, + ), + }); + + // Run the actual test + await withPromiseTrackerTestContext( + { + file: context.testFilePath, + suite: suite.name, + name: test.name, + fullName, + phase: 'test', + }, + async () => { + const result = test.fn(activeTestContext); + omitPromiseFromTracking(result); + await result; + }, + { omitReturnedPromise: true }, + ); + } catch (error) { + if (!isSkipTestError(error)) { + throw error; } - ); + + didSkip = true; + } finally { + // Run all afterEach hooks from the current suite and its parents + await runHooks(suite, 'afterEach', activeTestContext, { + wrapHook: (runHook) => + withPromiseTrackerTestContext( + { + file: context.testFilePath, + suite: suite.name, + name: test.name, + fullName, + phase: 'afterEach', + }, + runHook, + { omitReturnedPromise: true }, + ), + }); + } if (!didSkip) { await flushExpectTestState(expectTestState); @@ -643,7 +665,21 @@ export const runSuite = async ( // Run beforeAll hooks try { await withSuiteHookTimeout( - () => runHooks(suite, 'beforeAll'), + () => + runHooks(suite, 'beforeAll', undefined, { + wrapHook: (runHook) => + withPromiseTrackerTestContext( + { + file: context.testFilePath, + suite: suite.name, + name: 'beforeAll', + fullName: `${suite.name} beforeAll`, + phase: 'beforeAll', + }, + runHook, + { omitReturnedPromise: true }, + ), + }), { file: context.testFilePath, hookType: 'beforeAll', @@ -713,7 +749,21 @@ export const runSuite = async ( if (!state.interruptedByTimeout) { try { await withSuiteHookTimeout( - () => runHooks(suite, 'afterAll'), + () => + runHooks(suite, 'afterAll', undefined, { + wrapHook: (runHook) => + withPromiseTrackerTestContext( + { + file: context.testFilePath, + suite: suite.name, + name: 'afterAll', + fullName: `${suite.name} afterAll`, + phase: 'afterAll', + }, + runHook, + { omitReturnedPromise: true }, + ), + }), { file: context.testFilePath, hookType: 'afterAll', From 92327ee37f8522977c8e5bd6bc0a6c1ff884e26c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 3 Jun 2026 09:19:46 +0200 Subject: [PATCH 11/11] fix(runtime): suppress late timeout teardown Skip teardown and final assertion flushing when timed-out test work resumes after the timeout result has already been reported. --- .../src/__tests__/runner-context.test.ts | 4 +++ packages/runtime/src/runner/runSuite.ts | 36 +++++++++---------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/runtime/src/__tests__/runner-context.test.ts b/packages/runtime/src/__tests__/runner-context.test.ts index 676f0ff7..eda761ef 100644 --- a/packages/runtime/src/__tests__/runner-context.test.ts +++ b/packages/runtime/src/__tests__/runner-context.test.ts @@ -803,6 +803,10 @@ describe('runner task context', () => { try { const collection = await collector.collect(() => { harnessDescribe('Timeout Resume Suite', () => { + afterEach(() => { + calls.push('afterEach'); + }); + harnessIt( 'times out then resumes', async (context: HarnessTestContext) => { diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index 49c14c3f..ac4cb85a 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -469,27 +469,27 @@ const runTest = async ( didSkip = true; } finally { // Run all afterEach hooks from the current suite and its parents - await runHooks(suite, 'afterEach', activeTestContext, { - wrapHook: (runHook) => - withPromiseTrackerTestContext( - { - file: context.testFilePath, - suite: suite.name, - name: test.name, - fullName, - phase: 'afterEach', - }, - runHook, - { omitReturnedPromise: true }, - ), - }); + if (!timedOut) { + await runHooks(suite, 'afterEach', activeTestContext, { + wrapHook: (runHook) => + withPromiseTrackerTestContext( + { + file: context.testFilePath, + suite: suite.name, + name: test.name, + fullName, + phase: 'afterEach', + }, + runHook, + { omitReturnedPromise: true }, + ), + }); + } } - if (!didSkip) { + if (!didSkip && !timedOut) { await flushExpectTestState(expectTestState); - if (!timedOut) { - await runTestFinishedOnce(); - } + await runTestFinishedOnce(); } }, {