Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/jest/src/__tests__/execute-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<JestTestResult['console']>;
const session = makeSession({
Expand Down
53 changes: 53 additions & 0 deletions packages/jest/src/__tests__/harness-session.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -91,3 +93,54 @@ describe('waitForStartupCrash', () => {
expect(watch).not.toHaveBeenCalled();
});
});

describe('getSignalExitCodeForRunState', () => {
const createRunState = (
overrides: Partial<HarnessRunState> = {}
): 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);
});
});
3 changes: 2 additions & 1 deletion packages/jest/src/execute-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export const executeRun = async (
testFiles,
watchMode,
coverageEnabled: globalConfig.collectCoverage,
completed: false,
summary,
status: summary.failed > 0 ? 'failed' : 'passed',
...overrides,
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 30 additions & 5 deletions packages/jest/src/harness-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -148,6 +149,21 @@ export type HarnessSession = {
dispose: (reason?: 'normal' | 'abort' | 'error') => Promise<void>;
};

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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 22 additions & 4 deletions packages/tools/src/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ export { Subprocess, SubprocessError };

const activeChildProcesses = new Set<Subprocess>();
let isProcessCleanupInstalled = false;
let isTerminating = false;

type CleanupSignal = 'SIGINT' | 'SIGTERM';

const SIGNAL_EXIT_CODES: Record<CleanupSignal, number> = {
SIGINT: 130,
SIGTERM: 143,
};

const terminateActiveChildren = async () => {
const children = [...activeChildProcesses];
Expand All @@ -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');
});
};

Expand Down