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: 5 additions & 0 deletions .nx/version-plans/version-plan-1768232329713.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
__default__: prerelease
---

Add automatic app restart functionality when apps fail to report ready within the configured timeout period, improving test reliability by recovering from startup failures.
1 change: 1 addition & 0 deletions packages/bundler-metro/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { getMetroInstance } from './factory.js';
export type { MetroInstance, MetroFactory, MetroOptions } from './types.js';
export type { Reporter, ReportableEvent } from './reporter.js';
10 changes: 10 additions & 0 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ export const ConfigSchema = z
.min(1000, 'Bridge timeout must be at least 1 second')
.default(60000),

bundleStartTimeout: z
.number()
.min(1000, 'Bundle start timeout must be at least 1 second')
.default(15000),

maxAppRestarts: z
.number()
.min(0, 'Max app restarts must be non-negative')
.default(2),

resetEnvironmentBetweenTestFiles: z.boolean().optional().default(true),
unstable__skipAlreadyIncludedModules: z.boolean().optional().default(false),
unstable__enableMetroCache: z.boolean().optional().default(false),
Expand Down
1 change: 0 additions & 1 deletion packages/jest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@
"jest-message-util": "^30.2.0",
"jest-util": "^30.2.0",
"p-limit": "^7.1.1",
"p-retry": "^7.1.0",
"tslib": "^2.3.0",
"yargs": "^17.7.2"
},
Expand Down
10 changes: 10 additions & 0 deletions packages/jest/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,13 @@ export class InitializationTimeoutError extends HarnessError {
this.name = 'InitializationTimeoutError';
}
}

export class MaxAppRestartsError extends HarnessError {
constructor(attempts: number) {
super(
`App failed to start after ${attempts} attempts. ` +
`No bundling activity detected within timeout period.`
);
this.name = 'MaxAppRestartsError';
}
}
135 changes: 111 additions & 24 deletions packages/jest/src/harness.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,115 @@
import { getBridgeServer } from '@react-native-harness/bridge/server';
import {
getBridgeServer,
BridgeServer,
} from '@react-native-harness/bridge/server';
import { BridgeClientFunctions } from '@react-native-harness/bridge';
import { HarnessPlatform } from '@react-native-harness/platforms';
import { getMetroInstance } from '@react-native-harness/bundler-metro';
import { InitializationTimeoutError } from './errors.js';
import {
HarnessPlatform,
HarnessPlatformRunner,
} from '@react-native-harness/platforms';
import {
getMetroInstance,
Reporter,
ReportableEvent,
} from '@react-native-harness/bundler-metro';
import { InitializationTimeoutError, MaxAppRestartsError } from './errors.js';
import { Config as HarnessConfig } from '@react-native-harness/config';
import pRetry from 'p-retry';

const BRIDGE_READY_TIMEOUT = 10000;

export type Harness = {
runTests: BridgeClientFunctions['runTests'];
restart: () => Promise<void>;
dispose: () => Promise<void>;
};

export const waitForAppReady = async (options: {
metroEvents: Reporter;
serverBridge: BridgeServer;
platformInstance: HarnessPlatformRunner;
bundleStartTimeout: number;
maxRestarts: number;
signal: AbortSignal;
}): Promise<void> => {
const {
metroEvents,
serverBridge,
platformInstance,
bundleStartTimeout,
maxRestarts,
signal,
} = options;

let restartCount = 0;
let isBundling = false;
let bundleTimeoutId: NodeJS.Timeout | null = null;

const clearBundleTimeout = () => {
if (bundleTimeoutId) {
clearTimeout(bundleTimeoutId);
bundleTimeoutId = null;
}
};

return new Promise<void>((resolve, reject) => {
// Handle abort signal
signal.addEventListener('abort', () => {
clearBundleTimeout();
reject(new DOMException('The operation was aborted', 'AbortError'));
});

// Start/restart the bundle timeout
const startBundleTimeout = () => {
clearBundleTimeout();
bundleTimeoutId = setTimeout(() => {
if (isBundling) return; // Don't restart while bundling

if (restartCount >= maxRestarts) {
cleanup();
reject(new MaxAppRestartsError(restartCount + 1));
return;
}

restartCount++;
platformInstance.restartApp().catch(reject);
startBundleTimeout(); // Reset timer for next attempt
}, bundleStartTimeout);
};

// Metro event listener
const onMetroEvent = (event: ReportableEvent) => {
if (event.type === 'bundle_build_started') {
isBundling = true;
clearBundleTimeout(); // Cancel restart timer while bundling
} else if (
event.type === 'bundle_build_done' ||
event.type === 'bundle_build_failed'
) {
isBundling = false;
startBundleTimeout(); // Reset timer after bundle completes
}
};

// Bridge ready listener
const onReady = () => {
cleanup();
resolve();
};

const cleanup = () => {
clearBundleTimeout();
metroEvents.removeListener(onMetroEvent);
serverBridge.off('ready', onReady);
};

// Setup listeners
metroEvents.addListener(onMetroEvent);
serverBridge.once('ready', onReady);

// Start the app and timeout
platformInstance.restartApp().catch(reject);
startBundleTimeout();
});
};

const getHarnessInternal = async (
config: HarnessConfig,
platform: HarnessPlatform,
Expand Down Expand Up @@ -46,23 +142,14 @@ const getHarnessInternal = async (
}

try {
await pRetry(
() =>
new Promise<void>((resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new DOMException('The operation was aborted', 'AbortError'));
});

serverBridge.once('ready', () => resolve());
platformInstance.restartApp().catch(reject);
}),
{
minTimeout: BRIDGE_READY_TIMEOUT,
maxTimeout: BRIDGE_READY_TIMEOUT,
retries: Infinity,
signal,
}
);
await waitForAppReady({
metroEvents: metroInstance.events,
serverBridge,
platformInstance: platformInstance as HarnessPlatformRunner,
bundleStartTimeout: config.bundleStartTimeout,
maxRestarts: config.maxAppRestarts,
signal,
});
} catch (error) {
await dispose();
throw error;
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-android/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../config"
},
{
"path": "../tools"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-android/tsconfig.lib.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
},
"include": ["src/**/*.ts"],
"references": [
{
"path": "../config/tsconfig.lib.json"
},
{
"path": "../tools/tsconfig.lib.json"
},
Expand Down
Loading