@react-native-harness/jest 1.0.0-alpha.19 → 1.0.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-native-harness/jest",
3
- "version": "1.0.0-alpha.19",
3
+ "version": "1.0.0-alpha.21",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -38,14 +38,13 @@
38
38
  "jest-message-util": "^30.2.0",
39
39
  "jest-util": "^30.2.0",
40
40
  "p-limit": "^7.1.1",
41
- "p-retry": "^7.1.0",
42
41
  "tslib": "^2.3.0",
43
42
  "yargs": "^17.7.2",
44
- "@react-native-harness/bridge": "1.0.0-alpha.19",
45
- "@react-native-harness/config": "1.0.0-alpha.19",
46
- "@react-native-harness/bundler-metro": "1.0.0-alpha.19",
47
- "@react-native-harness/tools": "1.0.0-alpha.19",
48
- "@react-native-harness/platforms": "1.0.0-alpha.19"
43
+ "@react-native-harness/bridge": "1.0.0-alpha.21",
44
+ "@react-native-harness/bundler-metro": "1.0.0-alpha.21",
45
+ "@react-native-harness/config": "1.0.0-alpha.21",
46
+ "@react-native-harness/platforms": "1.0.0-alpha.21",
47
+ "@react-native-harness/tools": "1.0.0-alpha.21"
49
48
  },
50
49
  "devDependencies": {
51
50
  "@types/yargs": "^17.0.32",
@@ -0,0 +1,109 @@
1
+ import { HarnessPlatformRunner } from '@react-native-harness/platforms';
2
+ import { BridgeServer } from '@react-native-harness/bridge/server';
3
+ import { NativeCrashError } from './errors.js';
4
+ import { logger } from '@react-native-harness/tools';
5
+
6
+ export type CrashMonitor = {
7
+ startMonitoring(testFilePath: string): Promise<never>;
8
+ stopMonitoring(): void;
9
+ markIntentionalRestart(): void;
10
+ clearIntentionalRestart(): void;
11
+ dispose(): void;
12
+ };
13
+
14
+ export type CrashMonitorOptions = {
15
+ interval: number;
16
+ platformRunner: HarnessPlatformRunner;
17
+ bridgeServer: BridgeServer;
18
+ };
19
+
20
+ export const createCrashMonitor = ({
21
+ interval,
22
+ platformRunner,
23
+ bridgeServer,
24
+ }: CrashMonitorOptions): CrashMonitor => {
25
+ let pollingInterval: NodeJS.Timeout | null = null;
26
+ let isIntentionalRestart = false;
27
+ let currentTestFilePath: string | null = null;
28
+ let rejectFn: ((error: NativeCrashError) => void) | null = null;
29
+
30
+ const handleDisconnect = () => {
31
+ // Verify if it's actually a crash by checking if app is still running
32
+ if (!isIntentionalRestart && currentTestFilePath) {
33
+ // Capture the value to avoid it being null when setTimeout callback runs
34
+ const testFilePath = currentTestFilePath;
35
+ logger.debug('Bridge disconnected, checking if app crashed');
36
+ // Use a slight delay to allow the OS to clean up the process
37
+ setTimeout(async () => {
38
+ const isRunning = await platformRunner.isAppRunning();
39
+ if (!isRunning && !isIntentionalRestart && rejectFn) {
40
+ logger.debug(`Native crash detected during: ${testFilePath}`);
41
+ rejectFn(new NativeCrashError(testFilePath));
42
+ }
43
+ }, 100);
44
+ }
45
+ };
46
+
47
+ const startMonitoring = (testFilePath: string): Promise<never> => {
48
+ currentTestFilePath = testFilePath;
49
+
50
+ return new Promise<never>((_, reject) => {
51
+ rejectFn = reject;
52
+
53
+ // Listen for bridge disconnect as early indicator
54
+ bridgeServer.on('disconnect', handleDisconnect);
55
+
56
+ // Poll for app running status
57
+ pollingInterval = setInterval(async () => {
58
+ // Skip check during intentional restarts
59
+ if (isIntentionalRestart) {
60
+ return;
61
+ }
62
+
63
+ try {
64
+ const isRunning = await platformRunner.isAppRunning();
65
+
66
+ if (!isRunning && currentTestFilePath) {
67
+ logger.debug(
68
+ `Native crash detected during: ${currentTestFilePath}`
69
+ );
70
+ stopMonitoring();
71
+ reject(new NativeCrashError(currentTestFilePath));
72
+ }
73
+ } catch (error) {
74
+ logger.error('Error checking app status:', error);
75
+ }
76
+ }, interval);
77
+ });
78
+ };
79
+
80
+ const stopMonitoring = () => {
81
+ if (pollingInterval) {
82
+ clearInterval(pollingInterval);
83
+ pollingInterval = null;
84
+ }
85
+ bridgeServer.off('disconnect', handleDisconnect);
86
+ currentTestFilePath = null;
87
+ rejectFn = null;
88
+ };
89
+
90
+ const markIntentionalRestart = () => {
91
+ isIntentionalRestart = true;
92
+ };
93
+
94
+ const clearIntentionalRestart = () => {
95
+ isIntentionalRestart = false;
96
+ };
97
+
98
+ const dispose = () => {
99
+ stopMonitoring();
100
+ };
101
+
102
+ return {
103
+ startMonitoring,
104
+ stopMonitoring,
105
+ markIntentionalRestart,
106
+ clearIntentionalRestart,
107
+ dispose,
108
+ };
109
+ };
package/src/errors.ts CHANGED
@@ -20,3 +20,23 @@ export class InitializationTimeoutError extends HarnessError {
20
20
  this.name = 'InitializationTimeoutError';
21
21
  }
22
22
  }
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
+ }
33
+
34
+ export class NativeCrashError extends HarnessError {
35
+ constructor(
36
+ public readonly testFilePath: string,
37
+ public readonly lastKnownTest?: string
38
+ ) {
39
+ super('The native app crashed during test execution.');
40
+ this.name = 'NativeCrashError';
41
+ }
42
+ }
package/src/harness.ts CHANGED
@@ -1,17 +1,115 @@
1
- import { getBridgeServer } from '@react-native-harness/bridge/server';
1
+ import {
2
+ getBridgeServer,
3
+ BridgeServer,
4
+ } from '@react-native-harness/bridge/server';
2
5
  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';
6
16
  import { Config as HarnessConfig } from '@react-native-harness/config';
7
- import pRetry from 'p-retry';
8
-
9
- const BRIDGE_READY_TIMEOUT = 10000;
17
+ import { createCrashMonitor, CrashMonitor } from './crash-monitor.js';
10
18
 
11
19
  export type Harness = {
12
20
  runTests: BridgeClientFunctions['runTests'];
13
21
  restart: () => Promise<void>;
14
22
  dispose: () => Promise<void>;
23
+ crashMonitor: CrashMonitor;
24
+ };
25
+
26
+ export const waitForAppReady = async (options: {
27
+ metroEvents: Reporter;
28
+ serverBridge: BridgeServer;
29
+ platformInstance: HarnessPlatformRunner;
30
+ bundleStartTimeout: number;
31
+ maxRestarts: number;
32
+ signal: AbortSignal;
33
+ }): Promise<void> => {
34
+ const {
35
+ metroEvents,
36
+ serverBridge,
37
+ platformInstance,
38
+ bundleStartTimeout,
39
+ maxRestarts,
40
+ signal,
41
+ } = options;
42
+
43
+ let restartCount = 0;
44
+ let isBundling = false;
45
+ let bundleTimeoutId: NodeJS.Timeout | null = null;
46
+
47
+ const clearBundleTimeout = () => {
48
+ if (bundleTimeoutId) {
49
+ clearTimeout(bundleTimeoutId);
50
+ bundleTimeoutId = null;
51
+ }
52
+ };
53
+
54
+ return new Promise<void>((resolve, reject) => {
55
+ // Handle abort signal
56
+ signal.addEventListener('abort', () => {
57
+ clearBundleTimeout();
58
+ reject(new DOMException('The operation was aborted', 'AbortError'));
59
+ });
60
+
61
+ // Start/restart the bundle timeout
62
+ const startBundleTimeout = () => {
63
+ clearBundleTimeout();
64
+ bundleTimeoutId = setTimeout(() => {
65
+ if (isBundling) return; // Don't restart while bundling
66
+
67
+ if (restartCount >= maxRestarts) {
68
+ cleanup();
69
+ reject(new MaxAppRestartsError(restartCount + 1));
70
+ return;
71
+ }
72
+
73
+ restartCount++;
74
+ platformInstance.restartApp().catch(reject);
75
+ startBundleTimeout(); // Reset timer for next attempt
76
+ }, bundleStartTimeout);
77
+ };
78
+
79
+ // Metro event listener
80
+ const onMetroEvent = (event: ReportableEvent) => {
81
+ if (event.type === 'bundle_build_started') {
82
+ isBundling = true;
83
+ clearBundleTimeout(); // Cancel restart timer while bundling
84
+ } else if (
85
+ event.type === 'bundle_build_done' ||
86
+ event.type === 'bundle_build_failed'
87
+ ) {
88
+ isBundling = false;
89
+ startBundleTimeout(); // Reset timer after bundle completes
90
+ }
91
+ };
92
+
93
+ // Bridge ready listener
94
+ const onReady = () => {
95
+ cleanup();
96
+ resolve();
97
+ };
98
+
99
+ const cleanup = () => {
100
+ clearBundleTimeout();
101
+ metroEvents.removeListener(onMetroEvent);
102
+ serverBridge.off('ready', onReady);
103
+ };
104
+
105
+ // Setup listeners
106
+ metroEvents.addListener(onMetroEvent);
107
+ serverBridge.once('ready', onReady);
108
+
109
+ // Start the app and timeout
110
+ platformInstance.restartApp().catch(reject);
111
+ startBundleTimeout();
112
+ });
15
113
  };
16
114
 
17
115
  const getHarnessInternal = async (
@@ -22,9 +120,11 @@ const getHarnessInternal = async (
22
120
  ): Promise<Harness> => {
23
121
  const [metroInstance, platformInstance, serverBridge] = await Promise.all([
24
122
  getMetroInstance({ projectRoot, harnessConfig: config }, signal),
25
- import(platform.runner).then((module) => module.default(platform.config)),
123
+ import(platform.runner).then((module) =>
124
+ module.default(platform.config, config)
125
+ ),
26
126
  getBridgeServer({
27
- port: 3001,
127
+ port: config.webSocketPort,
28
128
  timeout: config.bridgeTimeout,
29
129
  }),
30
130
  ]);
@@ -37,6 +137,12 @@ const getHarnessInternal = async (
37
137
  ]);
38
138
  };
39
139
 
140
+ const crashMonitor = createCrashMonitor({
141
+ interval: config.crashDetectionInterval,
142
+ platformRunner: platformInstance,
143
+ bridgeServer: serverBridge,
144
+ });
145
+
40
146
  if (signal.aborted) {
41
147
  await dispose();
42
148
 
@@ -44,23 +150,14 @@ const getHarnessInternal = async (
44
150
  }
45
151
 
46
152
  try {
47
- await pRetry(
48
- () =>
49
- new Promise<void>((resolve, reject) => {
50
- signal.addEventListener('abort', () => {
51
- reject(new DOMException('The operation was aborted', 'AbortError'));
52
- });
53
-
54
- serverBridge.once('ready', () => resolve());
55
- platformInstance.restartApp().catch(reject);
56
- }),
57
- {
58
- minTimeout: BRIDGE_READY_TIMEOUT,
59
- maxTimeout: BRIDGE_READY_TIMEOUT,
60
- retries: Infinity,
61
- signal,
62
- }
63
- );
153
+ await waitForAppReady({
154
+ metroEvents: metroInstance.events,
155
+ serverBridge,
156
+ platformInstance: platformInstance as HarnessPlatformRunner,
157
+ bundleStartTimeout: config.bundleStartTimeout,
158
+ maxRestarts: config.maxAppRestarts,
159
+ signal,
160
+ });
64
161
  } catch (error) {
65
162
  await dispose();
66
163
  throw error;
@@ -68,7 +165,11 @@ const getHarnessInternal = async (
68
165
 
69
166
  const restart = () =>
70
167
  new Promise<void>((resolve, reject) => {
71
- serverBridge.once('ready', () => resolve());
168
+ crashMonitor.markIntentionalRestart();
169
+ serverBridge.once('ready', () => {
170
+ crashMonitor.clearIntentionalRestart();
171
+ resolve();
172
+ });
72
173
  platformInstance.restartApp().catch(reject);
73
174
  });
74
175
 
@@ -84,6 +185,7 @@ const getHarnessInternal = async (
84
185
  },
85
186
  restart,
86
187
  dispose,
188
+ crashMonitor,
87
189
  };
88
190
  };
89
191
 
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import { teardown } from './teardown.js';
17
17
  import { HarnessError } from '@react-native-harness/tools';
18
18
  import { getErrorMessage } from './logs.js';
19
19
  import { DeviceNotRespondingError } from '@react-native-harness/bridge';
20
+ import { NativeCrashError } from './errors.js';
20
21
 
21
22
  class CancelRun extends Error {
22
23
  constructor(message?: string) {
@@ -104,17 +105,52 @@ export default class JestHarness implements CallbackTestRunnerInterface {
104
105
  }
105
106
  isFirstTest = false;
106
107
 
107
- return onStart(test).then(() =>
108
- runHarnessTestFile({
109
- testPath: test.path,
110
- harness,
111
- globalConfig: this.#globalConfig,
112
- projectConfig: test.context.config,
113
- })
114
- );
108
+ return onStart(test).then(async () => {
109
+ if (!harnessConfig.detectNativeCrashes) {
110
+ return runHarnessTestFile({
111
+ testPath: test.path,
112
+ harness,
113
+ globalConfig: this.#globalConfig,
114
+ projectConfig: test.context.config,
115
+ });
116
+ }
117
+
118
+ // Start crash monitoring
119
+ const crashPromise = harness.crashMonitor.startMonitoring(
120
+ test.path
121
+ );
122
+
123
+ try {
124
+ const result = await Promise.race([
125
+ runHarnessTestFile({
126
+ testPath: test.path,
127
+ harness,
128
+ globalConfig: this.#globalConfig,
129
+ projectConfig: test.context.config,
130
+ }),
131
+ crashPromise,
132
+ ]);
133
+
134
+ return result;
135
+ } finally {
136
+ harness.crashMonitor.stopMonitoring();
137
+ }
138
+ });
115
139
  })
116
140
  .then((result) => onResult(test, result))
117
- .catch((err) => {
141
+ .catch(async (err) => {
142
+ if (err instanceof NativeCrashError) {
143
+ onFailure(test, {
144
+ message: err.message,
145
+ stack: '',
146
+ });
147
+
148
+ // Restart the app for the next test file
149
+ await harness.restart();
150
+
151
+ return;
152
+ }
153
+
118
154
  if (err instanceof DeviceNotRespondingError) {
119
155
  onFailure(test, {
120
156
  message: err.message,