From 9a59634766a477bb6084a13d2e0143528ec601ca Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Fri, 5 Jun 2026 13:33:26 +0200 Subject: [PATCH] fix(jest): preserve passing exit code during teardown --- .../jest/src/__tests__/execute-run.test.ts | 19 +++++++ .../src/__tests__/harness-session.test.ts | 53 +++++++++++++++++++ packages/jest/src/execute-run.ts | 3 +- packages/jest/src/harness-session.ts | 35 ++++++++++-- packages/tools/src/spawn.ts | 26 +++++++-- 5 files changed, 126 insertions(+), 10 deletions(-) diff --git a/packages/jest/src/__tests__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 3fe8bfae..f6b8da87 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -236,6 +236,25 @@ describe('executeRun', () => { expect(payload.status).toBe('passed'); }); + it('marks the final run state as completed', async () => { + const session = makeSession(); + + await executeRun( + session, + [makeTest()], + makeWatcher(), + makeEmitEvent().emitEvent, + makeGlobalConfig(), + ); + + expect(session.setRunState).toHaveBeenLastCalledWith( + expect.objectContaining({ + completed: true, + status: 'passed', + }), + ); + }); + it('attaches buffered client logs to the Jest result', async () => { const clientLogs = [{ message: 'Loaded screen', origin: '', type: 'warn' }] satisfies NonNullable; const session = makeSession({ diff --git a/packages/jest/src/__tests__/harness-session.test.ts b/packages/jest/src/__tests__/harness-session.test.ts index a86f4364..ed29e2b9 100644 --- a/packages/jest/src/__tests__/harness-session.test.ts +++ b/packages/jest/src/__tests__/harness-session.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { AppConnection } from '@react-native-harness/bridge/server'; import { + getSignalExitCodeForRunState, waitForBridgeDisconnectOrTimeout, waitForStartupCrash, + type HarnessRunState, } from '../harness-session.js'; import type { CrashMonitor } from '../crash-monitor.js'; @@ -91,3 +93,54 @@ describe('waitForStartupCrash', () => { expect(watch).not.toHaveBeenCalled(); }); }); + +describe('getSignalExitCodeForRunState', () => { + const createRunState = ( + overrides: Partial = {} + ): HarnessRunState => ({ + completed: true, + coverageEnabled: false, + runId: 'run-1', + startTime: 0, + status: 'passed', + summary: { + failed: 0, + passed: 1, + skipped: 0, + todo: 0, + }, + testFiles: ['test.harness.ts'], + watchMode: false, + ...overrides, + }); + + it('preserves a successful exit after a completed passing run', () => { + expect(getSignalExitCodeForRunState(createRunState())).toBe(0); + }); + + it('fails when the run has not completed yet', () => { + expect( + getSignalExitCodeForRunState(createRunState({ completed: false })) + ).toBe(1); + }); + + it('fails when the completed run has failures', () => { + expect( + getSignalExitCodeForRunState( + createRunState({ + status: 'failed', + summary: { + failed: 1, + passed: 0, + skipped: 0, + todo: 0, + }, + }) + ) + ).toBe(1); + }); + + it('fails when no run state is available', () => { + expect(getSignalExitCodeForRunState(null)).toBe(1); + }); +}); diff --git a/packages/jest/src/execute-run.ts b/packages/jest/src/execute-run.ts index 0ac3c56a..ffe542af 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -159,6 +159,7 @@ export const executeRun = async ( testFiles, watchMode, coverageEnabled: globalConfig.collectCoverage, + completed: false, summary, status: summary.failed > 0 ? 'failed' : 'passed', ...overrides, @@ -318,7 +319,7 @@ export const executeRun = async ( if (!(err instanceof CancelRun)) throw err; } finally { const runState = updateRunState( - runError != null ? { error: runError, status: 'failed' } : {}, + runError != null ? { completed: true, error: runError, status: 'failed' } : { completed: true }, ); await session.callHook('run:finished', { runId, diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index 4df5e9e4..3ea2865d 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -127,6 +127,7 @@ export type HarnessRunState = { readonly testFiles: string[]; readonly watchMode: boolean; readonly coverageEnabled: boolean; + readonly completed?: boolean; readonly summary?: HarnessRunSummary; readonly status?: HarnessRunStatus; readonly error?: unknown; @@ -148,6 +149,21 @@ export type HarnessSession = { dispose: (reason?: 'normal' | 'abort' | 'error') => Promise; }; +export const getSignalExitCodeForRunState = ( + state: HarnessRunState | null +): number => { + if ( + state?.completed && + state.status === 'passed' && + !state.error && + (state.summary?.failed ?? 0) === 0 + ) { + return 0; + } + + return 1; +}; + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- @@ -715,7 +731,13 @@ export const createHarnessSession = async ( // that all infrastructure is up. process.off('SIGTERM', onEarlySignal); process.off('SIGINT', onEarlySignal); - const onSignal = () => void dispose('abort').then(() => process.exit(0)); + const onSignal = () => { + const exitCode = getSignalExitCodeForRunState(currentRun); + void dispose('abort').then( + () => process.exit(exitCode), + () => process.exit(1), + ); + }; process.once('SIGTERM', onSignal); process.once('SIGINT', onSignal); @@ -854,10 +876,13 @@ export const createHarnessSession = async ( setRunState: (state) => { currentRun = state; }, - dispose: (reason = 'normal') => { - process.off('SIGTERM', onSignal); - process.off('SIGINT', onSignal); - return dispose(reason); + dispose: async (reason = 'normal') => { + try { + await dispose(reason); + } finally { + process.off('SIGTERM', onSignal); + process.off('SIGINT', onSignal); + } }, }; } catch (error) { diff --git a/packages/tools/src/spawn.ts b/packages/tools/src/spawn.ts index 65b0bbe0..4b183444 100644 --- a/packages/tools/src/spawn.ts +++ b/packages/tools/src/spawn.ts @@ -40,6 +40,14 @@ export { Subprocess, SubprocessError }; const activeChildProcesses = new Set(); let isProcessCleanupInstalled = false; +let isTerminating = false; + +type CleanupSignal = 'SIGINT' | 'SIGTERM'; + +const SIGNAL_EXIT_CODES: Record = { + SIGINT: 130, + SIGTERM: 143, +}; const terminateActiveChildren = async () => { const children = [...activeChildProcesses]; @@ -62,16 +70,26 @@ const installProcessCleanup = () => { isProcessCleanupInstalled = true; - const terminate = async () => { + const terminate = async (signal: CleanupSignal) => { + if (isTerminating) { + return; + } + + isTerminating = true; + const shouldExitAfterCleanup = process.listenerCount(signal) <= 1; + await terminateActiveChildren(); - process.exit(1); + + if (shouldExitAfterCleanup) { + process.exit(process.exitCode ?? SIGNAL_EXIT_CODES[signal]); + } }; process.on('SIGINT', () => { - void terminate(); + void terminate('SIGINT'); }); process.on('SIGTERM', () => { - void terminate(); + void terminate('SIGTERM'); }); };