Skip to content

Commit 55c4b53

Browse files
authored
feat: restart app if it fails to report back (#55)
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 parent 4e28360 commit 55c4b53

File tree

10 files changed

+195
-54
lines changed

10 files changed

+195
-54
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
__default__: prerelease
3+
---
4+
5+
Add automatic app restart functionality when apps fail to report ready within the configured timeout period, improving test reliability by recovering from startup failures.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { getMetroInstance } from './factory.js';
22
export type { MetroInstance, MetroFactory, MetroOptions } from './types.js';
3+
export type { Reporter, ReportableEvent } from './reporter.js';

packages/config/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ export const ConfigSchema = z
1414
.min(1000, 'Bridge timeout must be at least 1 second')
1515
.default(60000),
1616

17+
bundleStartTimeout: z
18+
.number()
19+
.min(1000, 'Bundle start timeout must be at least 1 second')
20+
.default(15000),
21+
22+
maxAppRestarts: z
23+
.number()
24+
.min(0, 'Max app restarts must be non-negative')
25+
.default(2),
26+
1727
resetEnvironmentBetweenTestFiles: z.boolean().optional().default(true),
1828
unstable__skipAlreadyIncludedModules: z.boolean().optional().default(false),
1929
unstable__enableMetroCache: z.boolean().optional().default(false),

packages/jest/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
"jest-message-util": "^30.2.0",
4444
"jest-util": "^30.2.0",
4545
"p-limit": "^7.1.1",
46-
"p-retry": "^7.1.0",
4746
"tslib": "^2.3.0",
4847
"yargs": "^17.7.2"
4948
},

packages/jest/src/errors.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,13 @@ export class InitializationTimeoutError extends HarnessError {
2020
this.name = 'InitializationTimeoutError';
2121
}
2222
}
23+
24+
export class MaxAppRestartsError extends HarnessError {
25+
constructor(attempts: number) {
26+
super(
27+
`App failed to start after ${attempts} attempts. ` +
28+
`No bundling activity detected within timeout period.`
29+
);
30+
this.name = 'MaxAppRestartsError';
31+
}
32+
}

packages/jest/src/harness.ts

Lines changed: 111 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,115 @@
1-
import { getBridgeServer } from '@react-native-harness/bridge/server';
1+
import {
2+
getBridgeServer,
3+
BridgeServer,
4+
} from '@react-native-harness/bridge/server';
25
import { BridgeClientFunctions } from '@react-native-harness/bridge';
3-
import { HarnessPlatform } from '@react-native-harness/platforms';
4-
import { getMetroInstance } from '@react-native-harness/bundler-metro';
5-
import { InitializationTimeoutError } from './errors.js';
6+
import {
7+
HarnessPlatform,
8+
HarnessPlatformRunner,
9+
} from '@react-native-harness/platforms';
10+
import {
11+
getMetroInstance,
12+
Reporter,
13+
ReportableEvent,
14+
} from '@react-native-harness/bundler-metro';
15+
import { InitializationTimeoutError, MaxAppRestartsError } from './errors.js';
616
import { Config as HarnessConfig } from '@react-native-harness/config';
7-
import pRetry from 'p-retry';
8-
9-
const BRIDGE_READY_TIMEOUT = 10000;
1017

1118
export type Harness = {
1219
runTests: BridgeClientFunctions['runTests'];
1320
restart: () => Promise<void>;
1421
dispose: () => Promise<void>;
1522
};
1623

24+
export const waitForAppReady = async (options: {
25+
metroEvents: Reporter;
26+
serverBridge: BridgeServer;
27+
platformInstance: HarnessPlatformRunner;
28+
bundleStartTimeout: number;
29+
maxRestarts: number;
30+
signal: AbortSignal;
31+
}): Promise<void> => {
32+
const {
33+
metroEvents,
34+
serverBridge,
35+
platformInstance,
36+
bundleStartTimeout,
37+
maxRestarts,
38+
signal,
39+
} = options;
40+
41+
let restartCount = 0;
42+
let isBundling = false;
43+
let bundleTimeoutId: NodeJS.Timeout | null = null;
44+
45+
const clearBundleTimeout = () => {
46+
if (bundleTimeoutId) {
47+
clearTimeout(bundleTimeoutId);
48+
bundleTimeoutId = null;
49+
}
50+
};
51+
52+
return new Promise<void>((resolve, reject) => {
53+
// Handle abort signal
54+
signal.addEventListener('abort', () => {
55+
clearBundleTimeout();
56+
reject(new DOMException('The operation was aborted', 'AbortError'));
57+
});
58+
59+
// Start/restart the bundle timeout
60+
const startBundleTimeout = () => {
61+
clearBundleTimeout();
62+
bundleTimeoutId = setTimeout(() => {
63+
if (isBundling) return; // Don't restart while bundling
64+
65+
if (restartCount >= maxRestarts) {
66+
cleanup();
67+
reject(new MaxAppRestartsError(restartCount + 1));
68+
return;
69+
}
70+
71+
restartCount++;
72+
platformInstance.restartApp().catch(reject);
73+
startBundleTimeout(); // Reset timer for next attempt
74+
}, bundleStartTimeout);
75+
};
76+
77+
// Metro event listener
78+
const onMetroEvent = (event: ReportableEvent) => {
79+
if (event.type === 'bundle_build_started') {
80+
isBundling = true;
81+
clearBundleTimeout(); // Cancel restart timer while bundling
82+
} else if (
83+
event.type === 'bundle_build_done' ||
84+
event.type === 'bundle_build_failed'
85+
) {
86+
isBundling = false;
87+
startBundleTimeout(); // Reset timer after bundle completes
88+
}
89+
};
90+
91+
// Bridge ready listener
92+
const onReady = () => {
93+
cleanup();
94+
resolve();
95+
};
96+
97+
const cleanup = () => {
98+
clearBundleTimeout();
99+
metroEvents.removeListener(onMetroEvent);
100+
serverBridge.off('ready', onReady);
101+
};
102+
103+
// Setup listeners
104+
metroEvents.addListener(onMetroEvent);
105+
serverBridge.once('ready', onReady);
106+
107+
// Start the app and timeout
108+
platformInstance.restartApp().catch(reject);
109+
startBundleTimeout();
110+
});
111+
};
112+
17113
const getHarnessInternal = async (
18114
config: HarnessConfig,
19115
platform: HarnessPlatform,
@@ -46,23 +142,14 @@ const getHarnessInternal = async (
46142
}
47143

48144
try {
49-
await pRetry(
50-
() =>
51-
new Promise<void>((resolve, reject) => {
52-
signal.addEventListener('abort', () => {
53-
reject(new DOMException('The operation was aborted', 'AbortError'));
54-
});
55-
56-
serverBridge.once('ready', () => resolve());
57-
platformInstance.restartApp().catch(reject);
58-
}),
59-
{
60-
minTimeout: BRIDGE_READY_TIMEOUT,
61-
maxTimeout: BRIDGE_READY_TIMEOUT,
62-
retries: Infinity,
63-
signal,
64-
}
65-
);
145+
await waitForAppReady({
146+
metroEvents: metroInstance.events,
147+
serverBridge,
148+
platformInstance: platformInstance as HarnessPlatformRunner,
149+
bundleStartTimeout: config.bundleStartTimeout,
150+
maxRestarts: config.maxAppRestarts,
151+
signal,
152+
});
66153
} catch (error) {
67154
await dispose();
68155
throw error;

packages/platform-android/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
"files": [],
44
"include": [],
55
"references": [
6+
{
7+
"path": "../config"
8+
},
69
{
710
"path": "../tools"
811
},

packages/platform-android/tsconfig.lib.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
},
1212
"include": ["src/**/*.ts"],
1313
"references": [
14+
{
15+
"path": "../config/tsconfig.lib.json"
16+
},
1417
{
1518
"path": "../tools/tsconfig.lib.json"
1619
},

0 commit comments

Comments
 (0)