diff --git a/.changeset/runtime-test-timeouts.md b/.changeset/runtime-test-timeouts.md new file mode 100644 index 00000000..3264410f --- /dev/null +++ b/.changeset/runtime-test-timeouts.md @@ -0,0 +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 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/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/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/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__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 632ce142..3fe8bfae 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'); } @@ -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', () => { @@ -461,6 +520,132 @@ describe('executeRun', () => { // restartApp should be called for tests 2 and 3, not test 1. expect(session.restartApp).toHaveBeenCalledTimes(2); }); + + it('restarts after a test case timeout before the next 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, + }), + })) + .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('/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'); + }); + + 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/__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/__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..9ee9df81 --- /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 Harness config testTimeout above Jest 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: 15000 }), + ); + }); +}); diff --git a/packages/jest/src/execute-run.ts b/packages/jest/src/execute-run.ts index 6d326bba..0ac3c56a 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -22,11 +22,13 @@ import type { TestRunnerEvents, TestRunnerTestFinishedEvent, TestRunnerTestStartedEvent, + TestSuiteResult, } from '@react-native-harness/bridge'; import { createPlatformSkippedTestResult, shouldRunHarnessTestFile, } from './test-file-platform-filter.js'; +import { formatHarnessErrorMessage } from './format-harness-error.js'; type EmitTestEvent = ( eventName: Name, @@ -86,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; @@ -95,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, @@ -114,6 +116,18 @@ const isHarnessCaseEvent = ( ): event is TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent => event.type === 'test-started' || event.type === 'test-finished'; +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, tests: Array, @@ -174,6 +188,7 @@ export const executeRun = async ( session.config.runners.map((runner) => runner.platformId), ); let isFirstTest = true; + let shouldRestartAfterTimeout = false; let runError: unknown; try { @@ -230,8 +245,9 @@ export const executeRun = async ( } try { - if (shouldResetEnv && !isFirstTest) { + if ((shouldResetEnv && !isFirstTest) || shouldRestartAfterTimeout) { await session.restartApp(test.path); + shouldRestartAfterTimeout = false; } isFirstTest = false; @@ -259,8 +275,14 @@ export const executeRun = async ( duration: result.duration, result: result.harnessResult, }); + const didRuntimeTimeout = hasRuntimeTimeout(result.harnessResult); + shouldRestartAfterTimeout = didRuntimeTimeout; await caseEventChain; await emitEvent('test-file-success', test, result.jestResult); + if (didRuntimeTimeout) { + await session.restartApp(test.path); + shouldRestartAfterTimeout = false; + } } catch (err) { if (!emittedTestFileFinished) { await emitTestFileFinished({ 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 43548ffb..506b323b 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 = ( @@ -16,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({ @@ -88,11 +100,18 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({ const setupFilesAfterEnv = projectConfig.setupFilesAfterEnv?.map( (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; const harnessResult = await session.runTestFile(relativeTestPath, { testNamePattern: globalConfig.testNamePattern, setupFiles, setupFilesAfterEnv, + testTimeout, runner: session.context.platform.runner, }); const end = Date.now(); @@ -102,14 +121,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__/promise-tracker.test.ts b/packages/runtime/src/__tests__/promise-tracker.test.ts new file mode 100644 index 00000000..3764397b --- /dev/null +++ b/packages/runtime/src/__tests__/promise-tracker.test.ts @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + clearTrackedPromises, + getPendingPromises, + installPromiseTracker, + type PromiseTrackerTestContext, + uninstallPromiseTracker, + withPromiseTrackerTestContext, +} from '../promise-tracker.js'; + +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(); + + 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('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(); + + 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( + testContext, + async () => { + void new Promise(() => undefined); + } + ); + + expect(getPendingPromises().filter((promise) => promise.test)).toEqual([ + expect.objectContaining({ + 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, + }), + ]); + }); + + 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..eda761ef 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, @@ -7,7 +9,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 +31,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 +53,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 +118,45 @@ 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', + phase: 'test', + }, + }), + ]); + } finally { + collector.dispose(); + runner.dispose(); + } + }); + it('keeps zero-argument tests and hooks working', async () => { const calls: string[] = []; const collector = getTestCollector(); @@ -196,13 +249,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 +288,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 +342,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 +384,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 +473,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 +565,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'); @@ -529,4 +588,255 @@ describe('runner task context', () => { runner.dispose(); } }); + + 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(); + 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'), + diagnostics: { + pendingPromises: { + total: expect.any(Number), + items: expect.arrayContaining([ + expect.objectContaining({ + stack: expect.stringContaining('Promise created'), + }), + ]), + }, + }, + }, + }, + { 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(); + } + }); + + 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(); + const runner = getTestRunner(); + + try { + const collection = await collector.collect(() => { + harnessDescribe('Timeout Resume Suite', () => { + afterEach(() => { + calls.push('afterEach'); + }); + + 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/client/factory.ts b/packages/runtime/src/client/factory.ts index 14a05830..ec39bae9 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; @@ -79,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/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..b1e785f7 --- /dev/null +++ b/packages/runtime/src/promise-tracker.ts @@ -0,0 +1,313 @@ +export type PromiseTrackerTestContext = { + file: string; + suite: string; + name: string; + fullName: string; + phase?: 'beforeAll' | 'beforeEach' | 'test' | 'afterEach' | 'afterAll'; +}; + +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(); +const promiseIds = new WeakMap(); +const promiseContexts = new WeakMap(); + +let originalPromise: PromiseConstructor | null = null; +let nextPromiseId = 1; +let currentTestContext: PromiseTrackerTestContext | undefined; +let trackingDisabledDepth = 0; + +const getOriginalPromise = (): PromiseConstructor => + originalPromise ?? globalThis.Promise; + +const createPromiseStack = (): string | undefined => { + try { + return new Error('Promise created').stack; + } catch { + return undefined; + } +}; + +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 { id: null }; + } + + const id = nextPromiseId++; + const test = getCurrentPromiseContext(); + + pendingPromises.set(id, { + id, + createdAt: Date.now(), + stack: createPromiseStack(), + test, + }); + + return { id, test }; +}; + +const markPromiseSettled = (id: number | null) => { + if (id === null) { + return; + } + + 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 registration = registerPromise(); + + super((resolve, reject) => { + try { + executor( + (value: T | PromiseLike) => { + if (isThenable(value)) { + runWithoutPromiseTracking(() => { + NativePromise.resolve(value).then( + () => markPromiseSettled(registration.id), + () => markPromiseSettled(registration.id) + ); + }); + } else { + markPromiseSettled(registration.id); + } + + resolve(value); + }, + (reason?: unknown) => { + markPromiseSettled(registration.id); + reject(reason); + } + ); + } catch (error) { + 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; + }) + ); + } + ); + } + } + + 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(); + // WeakMap entries are released with their promises. + 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 = ( + context: PromiseTrackerTestContext, + work: () => Promise, + options: { + omitReturnedPromise?: boolean; + } = {}, +): Promise => { + const previousContext = currentTestContext; + currentTestContext = context; + + try { + 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)); + } +}; + +export const runWithoutPromiseTracking = (work: () => T): T => { + trackingDisabledDepth += 1; + + try { + return work(); + } finally { + trackingDisabledDepth -= 1; + } +}; 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/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/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 1ec71d2f..ac4cb85a 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -10,9 +10,16 @@ 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 { + getPendingPromises, + omitPromiseFromTracking, + runWithoutPromiseTracking, + type TrackedPromiseRecord, + withPromiseTrackerTestContext, +} from '../promise-tracker.js'; import { createTestContext, createTestLifecycleState, @@ -42,6 +49,166 @@ const getAncestorTitles = (suite: TestSuite): string[] => { 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; + } +} + +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; +}; + +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 withRuntimeTimeout = async ( + work: () => Promise, + options: { + createTimeoutError: () => Error; + timeout: number; + }, +): Promise => { + let timeoutId: ReturnType | null = null; + + const timeoutPromise = runWithoutPromiseTracking( + () => + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(options.createTimeoutError()); + }, options.timeout); + }), + ); + const workPromise = work(); + + try { + return await runWithoutPromiseTracking(() => + Promise.race([workPromise, timeoutPromise]), + ); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +}; + +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 === 'beforeEach' || + promise.test.phase === 'test' || + promise.test.phase === 'afterEach'), + ); + + 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( + work, + { + timeout: options.timeout, + createTimeoutError: () => + new SuiteHookTimeoutError( + options.hookType, + options.suiteName, + options.timeout, + { + pendingPromises: getPendingPromiseDiagnostics( + getMatchingPendingPromises(), + ), + }, + ), + }, + ); +}; + const emitTestFinished = ( context: TestRunnerContext, options: { @@ -51,7 +218,7 @@ const emitTestFinished = ( duration: number; status: 'passed' | 'failed' | 'skipped' | 'todo'; error?: TestResult['error']; - }, + } ) => { const ancestorTitles = getAncestorTitles(options.suite); @@ -70,6 +237,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,6 +324,7 @@ const runTest = async ( test: TestCase, suite: TestSuite, context: TestRunnerContext, + state: RunSuiteState ): Promise => { const startedAt = Date.now(); const task: HarnessTaskContext = { @@ -87,8 +334,8 @@ const runTest = async ( test.status === 'active' ? 'run' : test.status === 'skipped' - ? 'skip' - : 'todo', + ? 'skip' + : 'todo', file: { name: context.testFilePath, }, @@ -99,8 +346,19 @@ const runTest = async ( const lifecycleState = createTestLifecycleState(); const activeTestContext: ActiveTestContext = createTestContext( task, - lifecycleState, + 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); @@ -165,29 +423,86 @@ 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; - } - - didSkip = true; - } finally { - // Run all afterEach hooks from the current suite and its parents - await runHooks(suite, 'afterEach', activeTestContext); - } + await withTestTimeout( + async () => { + 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 + 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 && !timedOut) { + await flushExpectTestState(expectTestState); + await runTestFinishedOnce(); + } + }, + { + file: context.testFilePath, + fullName, + timeout: getTestTimeout(context), + }, + ); if (didSkip) { const duration = Date.now() - startedAt; - await runOnTestFinished(lifecycleState); + await runTestFinishedOnce(); const result = { name: test.name, @@ -210,8 +525,6 @@ const runTest = async ( return result; } - await flushExpectTestState(expectTestState); - await runOnTestFinished(lifecycleState); } finally { setCurrentExpectTestState(undefined); } @@ -238,14 +551,19 @@ const runTest = async ( return result; } catch (error) { + if (error instanceof TestCaseTimeoutError) { + state.interruptedByTimeout = true; + timedOut = true; + } + await runOnTestFailed(lifecycleState); - await runOnTestFinished(lifecycleState); + await runTestFinishedOnce(); const testError = await getTestExecutionError( error, context.testFilePath, suite.name, - test.name, + test.name ); const duration = Date.now() - startedAt; @@ -276,6 +594,7 @@ const runTest = async ( export const runSuite = async ( suite: TestSuite, context: TestRunnerContext, + state: RunSuiteState = { interruptedByTimeout: false } ): Promise => { const startTime = Date.now(); @@ -289,12 +608,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, state) + ) ); const suiteResults = await Promise.all( suite.suites.map((childSuite) => - runSuite({ ...childSuite, status: 'skipped' }, context), - ), + runSuite({ ...childSuite, status: 'skipped' }, context, state) + ) ); const result = { @@ -342,22 +663,130 @@ export const runSuite = async ( const suiteResults: TestSuiteResult[] = []; // Run beforeAll hooks - await runHooks(suite, 'beforeAll'); + try { + await withSuiteHookTimeout( + () => + 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', + 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) { - 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'); + let suiteError: TestSuiteResult['error']; + if (!state.interruptedByTimeout) { + try { + await withSuiteHookTimeout( + () => + 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', + 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; @@ -366,13 +795,13 @@ 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) { + if (suiteError || hasFailedTests || hasFailedSuites) { status = 'failed'; } else { // Check if all tests and suites are skipped (and there are some tests/suites to check) @@ -407,6 +836,7 @@ export const runSuite = async ( file: context.testFilePath, name: suite.name, duration, + error: suiteError, status, }); @@ -415,6 +845,7 @@ export const runSuite = async ( tests: testResults, suites: suiteResults, status, + error: suiteError, duration, }; }; 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 = { diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index f2003098..eee184e3 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`). 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. | @@ -212,7 +213,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. 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. ```javascript { @@ -223,10 +226,25 @@ 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 + +## 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) + +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 @@ -320,6 +338,7 @@ const config = { platformReadyTimeout: isCI ? 420000 : 300000, bridgeTimeout: isCI ? 180000 : 60000, + testTimeout: isCI ? 30000 : 5000, }; export default config;