Skip to content
Merged
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
5 changes: 0 additions & 5 deletions .nx/version-plans/version-plan-1785770217523.md

This file was deleted.

5 changes: 0 additions & 5 deletions .nx/version-plans/version-plan-1785777985382.md

This file was deleted.

5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1785922736004.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
__default__: patch
---

Harness now bounds owned CLI processes so cancelled runs do not leave console, logcat, or XCTest children behind.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## 1.4.1 (2026-08-04)

### 🩹 Fixes

- Published packages exclude test outputs and local build artifacts. ([#179](https://github.com/callstackincubator/react-native-harness/pull/179))
- Harness tracks pending work while preserving native Promise instances so native modules return their settled values on Hermes. ([#180](https://github.com/callstackincubator/react-native-harness/pull/180))

### ❤️ Thank You

- Szymon Chmal @V3RON

## 1.4.0 (2026-08-03)

### 🚀 Features
Expand Down
2 changes: 1 addition & 1 deletion packages/babel-preset/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-native-harness/babel-preset",
"version": "1.4.0",
"version": "1.4.1",
"type": "commonjs",
"main": "./dist/index.js",
"module": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/bridge/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-native-harness/bridge",
"version": "1.4.0",
"version": "1.4.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/bundler-metro/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@react-native-harness/bundler-metro",
"description": "Metro bundler for React Native Harness",
"version": "1.4.0",
"version": "1.4.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
Expand Down
32 changes: 20 additions & 12 deletions packages/bundler-metro/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,22 @@ export const getMetroInstance = async (
socket.on('close', () => sockets.delete(socket));
});

abortSignal.throwIfAborted();
const closeServer = () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
server.closeAllConnections();
for (const socket of sockets) {
socket.destroy();
}
});

await ready;
try {
abortSignal.throwIfAborted();
await ready;
} catch (error) {
await closeServer();
throw error;
}

metroLogger.debug('Metro server is running');

Expand Down Expand Up @@ -265,15 +278,10 @@ export const getMetroInstance = async (
},
getPrewarmState: () => prewarmState,
isBuildInFlight: () => buildsInFlight > 0,
dispose: () =>
new Promise<void>((resolve) => {
reporter.removeListener(onBundleRequestObserved);
reporter.removeListener(onBuildEvent);
server.close(() => resolve());
server.closeAllConnections();
for (const socket of sockets) {
socket.destroy();
}
}),
dispose: () => {
reporter.removeListener(onBundleRequestObserved);
reporter.removeListener(onBuildEvent);
return closeServer();
},
};
};
2 changes: 1 addition & 1 deletion packages/cache/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-native-harness/cache",
"version": "1.4.0",
"version": "1.4.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-native-harness/cli",
"version": "1.4.0",
"version": "1.4.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-native-harness/config",
"version": "1.4.0",
"version": "1.4.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/coverage-ios/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@react-native-harness/coverage-ios",
"description": "Native iOS code coverage support for React Native Harness.",
"version": "1.4.0",
"version": "1.4.1",
"type": "module",
"exports": {
"./package.json": "./package.json",
Expand Down
2 changes: 1 addition & 1 deletion packages/github-action/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@react-native-harness/github-action",
"private": true,
"description": "GitHub Actions for React Native Harness",
"version": "1.4.0",
"version": "1.4.1",
"type": "commonjs",
"exports": {
"./package.json": "./package.json"
Expand Down
2 changes: 1 addition & 1 deletion packages/jest/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-native-harness/jest",
"version": "1.4.0",
"version": "1.4.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
Expand Down
110 changes: 83 additions & 27 deletions packages/jest/src/harness-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import {
createCrashArtifactWriter,
createDiagnostics,
delay,
logger,
getTimeoutSignal,
ensureSignalsDeliverable,
Expand Down Expand Up @@ -94,6 +95,25 @@ const ignorePromiseRejection = () => undefined;
const isBridgeDisconnectError = (error: unknown) =>
error instanceof Error && error.message === 'App bridge disconnected';
const TEST_RUN_BRIDGE_STABILITY_WAIT_MS = 500;
const DISPOSE_HOOK_TIMEOUT_MS = 5_000;

const waitForBoundedSettlement = async <T>(
promise: Promise<T>,
timeoutMs: number
): Promise<PromiseSettledResult<T> | null> => {
const timeout = delay(timeoutMs);
try {
return await Promise.race([
promise.then(
(value) => ({ status: 'fulfilled', value }) as const,
(reason) => ({ status: 'rejected', reason }) as const
),
timeout.promise.then(() => null),
]);
} finally {
timeout.cancel();
}
};

type DisconnectObservableBridge = {
readonly connection: AppConnection | null;
Expand Down Expand Up @@ -487,7 +507,11 @@ export const createHarnessSession = async (
// child the runner threads it into.
ensureSignalsDeliverable();
const sessionController = new AbortController();
const onEarlySignal = () => sessionController.abort();
let receivedEarlySignal = false;
const onEarlySignal = () => {
receivedEarlySignal = true;
sessionController.abort();
};
process.once('SIGTERM', onEarlySignal);
process.once('SIGINT', onEarlySignal);

Expand Down Expand Up @@ -584,6 +608,11 @@ export const createHarnessSession = async (

let metroInstance: MetroInstance;
let platformInstance: HarnessPlatformRunner;
const initialized = {
metro: null as MetroInstance | null,
platform: null as HarnessPlatformRunner | null,
};
let initializationCancelled = false;
// Noop until the Metro instance resolves and the deriver is attached.
let disposeMetroDiagnostics: () => void = () => undefined;

Expand All @@ -601,6 +630,10 @@ export const createHarnessSession = async (
},
sessionController.signal,
).then((instance) => {
if (initializationCancelled) {
return instance.dispose().then(() => instance);
}
initialized.metro = instance;
sessionLogger.debug('Metro initialized');
// Attach the Metro diagnostics deriver before the eager prewarm
// fires so its bundle build/request events land in the trace.
Expand Down Expand Up @@ -638,6 +671,10 @@ export const createHarnessSession = async (
diagnostics,
} satisfies HarnessPlatformInitOptions),
).then((instance) => {
if (initializationCancelled) {
return instance.dispose().then(() => instance);
}
initialized.platform = instance;
sessionLogger.debug('platform runner initialized');
return instance;
});
Expand All @@ -647,10 +684,16 @@ export const createHarnessSession = async (
),
]);
} catch (error) {
// Only bridge and the Metro diagnostics listener need cleanup here;
// leases are released by the outer catch.
// Roll back every resource whose initialization won a concurrent race.
// Leases are released by the outer catch.
initializationCancelled = true;
sessionController.abort();
disposeMetroDiagnostics();
await bridge.dispose();
await Promise.allSettled([
bridge.dispose(),
initialized.metro?.dispose(),
initialized.platform?.dispose(),
]);
throw error;
}

Expand Down Expand Up @@ -759,7 +802,11 @@ export const createHarnessSession = async (
const disposeSpan = diagnostics.start('dispose.total', { reason });
let hookError: unknown;

try {
// Abort only cancels finite/in-flight work. Ownership remains explicit
// below, so this cannot race a platform or app-session disposal path.
sessionController.abort();

const hookWork = (async () => {
await hooks.drain();
await pluginManager.callHook('harness:after-run', {
runId: currentRun?.runId,
Expand All @@ -777,8 +824,17 @@ export const createHarnessSession = async (
error: currentRun?.error,
});
await hooks.drain();
} catch (error) {
hookError = error;
})();
const hookResult = await waitForBoundedSettlement(
hookWork,
DISPOSE_HOOK_TIMEOUT_MS
);
if (hookResult === null) {
hookError = new Error(
`Timed out after ${DISPOSE_HOOK_TIMEOUT_MS}ms while running disposal hooks.`
);
} else if (hookResult.status === 'rejected') {
hookError = hookResult.reason;
}

if (runtimeConfig.forwardClientLogs) {
Expand Down Expand Up @@ -809,26 +865,23 @@ export const createHarnessSession = async (
}
}

let cleanupError: unknown;
try {
// The app session (and its logcat/device log stream) must be fully
// torn down before the platform is disposed: platform disposal can
// shut down the device/emulator, and doing that concurrently with
// the app session's own cleanup races the device's log stream
// against the device disappearing out from under it.
await Promise.all([crashMonitor.dispose(), disposeCurrentAppSession()]);
await Promise.all([
bridge.dispose(),
platformInstance.dispose(),
metroInstance.dispose(),
metroPortLease?.release(),
]);
} catch (error) {
cleanupError = error;
} finally {
await resourceLease.release();
sessionController.abort();
}
// The app session (and its log streams) must finish before platform
// disposal can remove the underlying device. Run independent cleanup
// all-settled so one failure cannot strand another owned resource.
const appCleanup = await Promise.allSettled([
crashMonitor.dispose(),
disposeCurrentAppSession(),
]);
const infrastructureCleanup = await Promise.allSettled([
bridge.dispose(),
platformInstance.dispose(),
metroInstance.dispose(),
metroPortLease?.release(),
]);
const leaseCleanup = await Promise.allSettled([resourceLease.release()]);
const cleanupError = [...appCleanup, ...infrastructureCleanup, ...leaseCleanup]
.find((result): result is PromiseRejectedResult => result.status === 'rejected')
?.reason;

sessionLogger.debug('session resources disposed');

Expand Down Expand Up @@ -1102,6 +1155,9 @@ export const createHarnessSession = async (
// (e.g. a platform init call abandoned after a readiness timeout race).
sessionController.abort();
await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]);
if (receivedEarlySignal) {
process.exit(1);
}
throw error;
}
};
2 changes: 1 addition & 1 deletion packages/metro/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-native-harness/metro",
"version": "1.4.0",
"version": "1.4.1",
"type": "commonjs",
"main": "./dist/index.js",
"module": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-android/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@react-native-harness/platform-android",
"description": "Android platform for React Native Harness",
"version": "1.4.0",
"version": "1.4.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
Expand Down
Loading
Loading