@react-native-harness/jest 1.0.0-alpha.20 → 1.0.0-alpha.22

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.20",
3
+ "version": "1.0.0-alpha.22",
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/bundler-metro": "1.0.0-alpha.20",
45
- "@react-native-harness/config": "1.0.0-alpha.20",
46
- "@react-native-harness/platforms": "1.0.0-alpha.20",
47
- "@react-native-harness/tools": "1.0.0-alpha.20",
48
- "@react-native-harness/bridge": "1.0.0-alpha.20"
43
+ "@react-native-harness/bundler-metro": "1.0.0-alpha.22",
44
+ "@react-native-harness/config": "1.0.0-alpha.22",
45
+ "@react-native-harness/platforms": "1.0.0-alpha.22",
46
+ "@react-native-harness/bridge": "1.0.0-alpha.22",
47
+ "@react-native-harness/tools": "1.0.0-alpha.22"
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,125 @@
1
- import { getBridgeServer } from '@react-native-harness/bridge/server';
2
- 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';
1
+ import {
2
+ getBridgeServer,
3
+ BridgeServer,
4
+ } from '@react-native-harness/bridge/server';
5
+ import {
6
+ HarnessContext,
7
+ TestExecutionOptions,
8
+ TestSuiteResult,
9
+ } from '@react-native-harness/bridge';
10
+ import {
11
+ HarnessPlatform,
12
+ HarnessPlatformRunner,
13
+ } from '@react-native-harness/platforms';
14
+ import {
15
+ getMetroInstance,
16
+ Reporter,
17
+ ReportableEvent,
18
+ } from '@react-native-harness/bundler-metro';
19
+ import { InitializationTimeoutError, MaxAppRestartsError } from './errors.js';
6
20
  import { Config as HarnessConfig } from '@react-native-harness/config';
7
- import pRetry from 'p-retry';
21
+ import { createCrashMonitor, CrashMonitor } from './crash-monitor.js';
8
22
 
9
- const BRIDGE_READY_TIMEOUT = 10000;
23
+ export type HarnessRunTestsOptions = Exclude<TestExecutionOptions, 'platform'>;
10
24
 
11
25
  export type Harness = {
12
- runTests: BridgeClientFunctions['runTests'];
26
+ context: HarnessContext;
27
+ runTests: (
28
+ path: string,
29
+ options: HarnessRunTestsOptions
30
+ ) => Promise<TestSuiteResult>;
13
31
  restart: () => Promise<void>;
14
32
  dispose: () => Promise<void>;
33
+ crashMonitor: CrashMonitor;
34
+ };
35
+
36
+ export const waitForAppReady = async (options: {
37
+ metroEvents: Reporter;
38
+ serverBridge: BridgeServer;
39
+ platformInstance: HarnessPlatformRunner;
40
+ bundleStartTimeout: number;
41
+ maxRestarts: number;
42
+ signal: AbortSignal;
43
+ }): Promise<void> => {
44
+ const {
45
+ metroEvents,
46
+ serverBridge,
47
+ platformInstance,
48
+ bundleStartTimeout,
49
+ maxRestarts,
50
+ signal,
51
+ } = options;
52
+
53
+ let restartCount = 0;
54
+ let isBundling = false;
55
+ let bundleTimeoutId: NodeJS.Timeout | null = null;
56
+
57
+ const clearBundleTimeout = () => {
58
+ if (bundleTimeoutId) {
59
+ clearTimeout(bundleTimeoutId);
60
+ bundleTimeoutId = null;
61
+ }
62
+ };
63
+
64
+ return new Promise<void>((resolve, reject) => {
65
+ // Handle abort signal
66
+ signal.addEventListener('abort', () => {
67
+ clearBundleTimeout();
68
+ reject(new DOMException('The operation was aborted', 'AbortError'));
69
+ });
70
+
71
+ // Start/restart the bundle timeout
72
+ const startBundleTimeout = () => {
73
+ clearBundleTimeout();
74
+ bundleTimeoutId = setTimeout(() => {
75
+ if (isBundling) return; // Don't restart while bundling
76
+
77
+ if (restartCount >= maxRestarts) {
78
+ cleanup();
79
+ reject(new MaxAppRestartsError(restartCount + 1));
80
+ return;
81
+ }
82
+
83
+ restartCount++;
84
+ platformInstance.restartApp().catch(reject);
85
+ startBundleTimeout(); // Reset timer for next attempt
86
+ }, bundleStartTimeout);
87
+ };
88
+
89
+ // Metro event listener
90
+ const onMetroEvent = (event: ReportableEvent) => {
91
+ if (event.type === 'bundle_build_started') {
92
+ isBundling = true;
93
+ clearBundleTimeout(); // Cancel restart timer while bundling
94
+ } else if (
95
+ event.type === 'bundle_build_done' ||
96
+ event.type === 'bundle_build_failed'
97
+ ) {
98
+ isBundling = false;
99
+ startBundleTimeout(); // Reset timer after bundle completes
100
+ }
101
+ };
102
+
103
+ // Bridge ready listener
104
+ const onReady = () => {
105
+ cleanup();
106
+ resolve();
107
+ };
108
+
109
+ const cleanup = () => {
110
+ clearBundleTimeout();
111
+ metroEvents.removeListener(onMetroEvent);
112
+ serverBridge.off('ready', onReady);
113
+ };
114
+
115
+ // Setup listeners
116
+ metroEvents.addListener(onMetroEvent);
117
+ serverBridge.once('ready', onReady);
118
+
119
+ // Start the app and timeout
120
+ platformInstance.restartApp().catch(reject);
121
+ startBundleTimeout();
122
+ });
15
123
  };
16
124
 
17
125
  const getHarnessInternal = async (
@@ -20,6 +128,10 @@ const getHarnessInternal = async (
20
128
  projectRoot: string,
21
129
  signal: AbortSignal
22
130
  ): Promise<Harness> => {
131
+ const context: HarnessContext = {
132
+ platform,
133
+ };
134
+
23
135
  const [metroInstance, platformInstance, serverBridge] = await Promise.all([
24
136
  getMetroInstance({ projectRoot, harnessConfig: config }, signal),
25
137
  import(platform.runner).then((module) =>
@@ -28,6 +140,7 @@ const getHarnessInternal = async (
28
140
  getBridgeServer({
29
141
  port: config.webSocketPort,
30
142
  timeout: config.bridgeTimeout,
143
+ context,
31
144
  }),
32
145
  ]);
33
146
 
@@ -39,6 +152,12 @@ const getHarnessInternal = async (
39
152
  ]);
40
153
  };
41
154
 
155
+ const crashMonitor = createCrashMonitor({
156
+ interval: config.crashDetectionInterval,
157
+ platformRunner: platformInstance,
158
+ bridgeServer: serverBridge,
159
+ });
160
+
42
161
  if (signal.aborted) {
43
162
  await dispose();
44
163
 
@@ -46,23 +165,14 @@ const getHarnessInternal = async (
46
165
  }
47
166
 
48
167
  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
- );
168
+ await waitForAppReady({
169
+ metroEvents: metroInstance.events,
170
+ serverBridge,
171
+ platformInstance: platformInstance as HarnessPlatformRunner,
172
+ bundleStartTimeout: config.bundleStartTimeout,
173
+ maxRestarts: config.maxAppRestarts,
174
+ signal,
175
+ });
66
176
  } catch (error) {
67
177
  await dispose();
68
178
  throw error;
@@ -70,11 +180,16 @@ const getHarnessInternal = async (
70
180
 
71
181
  const restart = () =>
72
182
  new Promise<void>((resolve, reject) => {
73
- serverBridge.once('ready', () => resolve());
183
+ crashMonitor.markIntentionalRestart();
184
+ serverBridge.once('ready', () => {
185
+ crashMonitor.clearIntentionalRestart();
186
+ resolve();
187
+ });
74
188
  platformInstance.restartApp().catch(reject);
75
189
  });
76
190
 
77
191
  return {
192
+ context,
78
193
  runTests: async (path, options) => {
79
194
  const client = serverBridge.rpc.clients.at(-1);
80
195
 
@@ -82,10 +197,14 @@ const getHarnessInternal = async (
82
197
  throw new Error('No client found');
83
198
  }
84
199
 
85
- return await client.runTests(path, options);
200
+ return await client.runTests(path, {
201
+ ...options,
202
+ runner: platform.runner,
203
+ });
86
204
  },
87
205
  restart,
88
206
  dispose,
207
+ crashMonitor,
89
208
  };
90
209
  };
91
210
 
package/src/index.ts CHANGED
@@ -16,7 +16,8 @@ import { setup } from './setup.js';
16
16
  import { teardown } from './teardown.js';
17
17
  import { HarnessError } from '@react-native-harness/tools';
18
18
  import { getErrorMessage } from './logs.js';
19
- import { DeviceNotRespondingError } from '@react-native-harness/bridge';
19
+ import { DeviceNotRespondingError } from '@react-native-harness/bridge/server';
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,
package/src/run.ts CHANGED
@@ -88,6 +88,7 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({
88
88
  testNamePattern: globalConfig.testNamePattern,
89
89
  setupFiles,
90
90
  setupFilesAfterEnv,
91
+ runner: harness.context.platform.runner,
91
92
  });
92
93
  const end = Date.now();
93
94
 
package/tsconfig.json CHANGED
@@ -6,9 +6,6 @@
6
6
  {
7
7
  "path": "../cli"
8
8
  },
9
- {
10
- "path": "../bundler-metro"
11
- },
12
9
  {
13
10
  "path": "../tools"
14
11
  },
@@ -18,6 +15,9 @@
18
15
  {
19
16
  "path": "../config"
20
17
  },
18
+ {
19
+ "path": "../bundler-metro"
20
+ },
21
21
  {
22
22
  "path": "../bridge"
23
23
  },