@react-native-harness/jest 1.3.0 → 1.4.0

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.
Files changed (98) hide show
  1. package/dist/__tests__/bridge.test.js +29 -4
  2. package/dist/__tests__/crash-monitor.test.js +194 -176
  3. package/dist/__tests__/environment-reset.test.d.ts +2 -0
  4. package/dist/__tests__/environment-reset.test.d.ts.map +1 -0
  5. package/dist/__tests__/environment-reset.test.js +238 -0
  6. package/dist/__tests__/errors.test.js +39 -0
  7. package/dist/__tests__/execute-run.test.js +439 -29
  8. package/dist/__tests__/format-harness-error.test.d.ts +2 -0
  9. package/dist/__tests__/format-harness-error.test.d.ts.map +1 -0
  10. package/dist/__tests__/format-harness-error.test.js +27 -0
  11. package/dist/__tests__/harness-cache.test.js +62 -41
  12. package/dist/__tests__/harness-session-signal.test.d.ts +2 -0
  13. package/dist/__tests__/harness-session-signal.test.d.ts.map +1 -0
  14. package/dist/__tests__/harness-session-signal.test.js +298 -0
  15. package/dist/__tests__/harness-session.test.js +217 -234
  16. package/dist/__tests__/index.test.js +62 -11
  17. package/dist/__tests__/metro-port.test.js +2 -0
  18. package/dist/__tests__/run.test.d.ts +2 -0
  19. package/dist/__tests__/run.test.d.ts.map +1 -0
  20. package/dist/__tests__/run.test.js +48 -0
  21. package/dist/__tests__/test-file-platform-filter.test.d.ts +2 -0
  22. package/dist/__tests__/test-file-platform-filter.test.d.ts.map +1 -0
  23. package/dist/__tests__/test-file-platform-filter.test.js +47 -0
  24. package/dist/crash-monitor.d.ts +12 -4
  25. package/dist/crash-monitor.d.ts.map +1 -1
  26. package/dist/crash-monitor.js +135 -102
  27. package/dist/diagnostics/__tests__/derivers.test.d.ts +2 -0
  28. package/dist/diagnostics/__tests__/derivers.test.d.ts.map +1 -0
  29. package/dist/diagnostics/__tests__/derivers.test.js +248 -0
  30. package/dist/diagnostics/__tests__/report.test.d.ts +2 -0
  31. package/dist/diagnostics/__tests__/report.test.d.ts.map +1 -0
  32. package/dist/diagnostics/__tests__/report.test.js +97 -0
  33. package/dist/diagnostics/derivers.d.ts +24 -0
  34. package/dist/diagnostics/derivers.d.ts.map +1 -0
  35. package/dist/diagnostics/derivers.js +146 -0
  36. package/dist/diagnostics/index.d.ts +3 -0
  37. package/dist/diagnostics/index.d.ts.map +1 -0
  38. package/dist/diagnostics/index.js +2 -0
  39. package/dist/diagnostics/report.d.ts +7 -0
  40. package/dist/diagnostics/report.d.ts.map +1 -0
  41. package/dist/diagnostics/report.js +84 -0
  42. package/dist/environment-reset.d.ts +51 -0
  43. package/dist/environment-reset.d.ts.map +1 -0
  44. package/dist/environment-reset.js +83 -0
  45. package/dist/errors.d.ts +10 -0
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/errors.js +41 -2
  48. package/dist/execute-run.d.ts +4 -2
  49. package/dist/execute-run.d.ts.map +1 -1
  50. package/dist/execute-run.js +179 -49
  51. package/dist/format-harness-error.d.ts +5 -0
  52. package/dist/format-harness-error.d.ts.map +1 -0
  53. package/dist/format-harness-error.js +43 -0
  54. package/dist/harness-session.d.ts +46 -1
  55. package/dist/harness-session.d.ts.map +1 -1
  56. package/dist/harness-session.js +386 -118
  57. package/dist/index.d.ts +7 -3
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +24 -2
  60. package/dist/run.d.ts.map +1 -1
  61. package/dist/run.js +22 -4
  62. package/dist/test-file-platform-filter.d.ts +5 -0
  63. package/dist/test-file-platform-filter.d.ts.map +1 -0
  64. package/dist/test-file-platform-filter.js +40 -0
  65. package/dist/toTestResult.d.ts +1 -0
  66. package/dist/toTestResult.d.ts.map +1 -1
  67. package/dist/toTestResult.js +2 -2
  68. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  69. package/package.json +8 -7
  70. package/src/__tests__/bridge.test.ts +45 -5
  71. package/src/__tests__/crash-monitor.test.ts +234 -199
  72. package/src/__tests__/environment-reset.test.ts +308 -0
  73. package/src/__tests__/errors.test.ts +75 -0
  74. package/src/__tests__/execute-run.test.ts +561 -37
  75. package/src/__tests__/format-harness-error.test.ts +32 -0
  76. package/src/__tests__/harness-cache.test.ts +91 -0
  77. package/src/__tests__/harness-session.test.ts +303 -0
  78. package/src/__tests__/index.test.ts +92 -12
  79. package/src/__tests__/metro-port.test.ts +2 -0
  80. package/src/__tests__/run.test.ts +74 -0
  81. package/src/__tests__/test-file-platform-filter.test.ts +83 -0
  82. package/src/crash-monitor.ts +220 -131
  83. package/src/diagnostics/__tests__/derivers.test.ts +300 -0
  84. package/src/diagnostics/__tests__/report.test.ts +128 -0
  85. package/src/diagnostics/derivers.ts +170 -0
  86. package/src/diagnostics/index.ts +2 -0
  87. package/src/diagnostics/report.ts +124 -0
  88. package/src/environment-reset.ts +138 -0
  89. package/src/errors.ts +62 -1
  90. package/src/execute-run.ts +230 -57
  91. package/src/format-harness-error.ts +73 -0
  92. package/src/harness-session.ts +535 -162
  93. package/src/index.ts +40 -11
  94. package/src/run.ts +24 -4
  95. package/src/test-file-platform-filter.ts +57 -0
  96. package/src/toTestResult.ts +3 -2
  97. package/tsconfig.json +3 -0
  98. package/tsconfig.lib.json +3 -0
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ createPlatformSkippedTestResult,
4
+ getHarnessTestFilePlatform,
5
+ shouldRunHarnessTestFile,
6
+ } from '../test-file-platform-filter.js';
7
+
8
+ const knownPlatformIds = new Set(['android', 'ios', 'web', 'vega']);
9
+
10
+ describe('getHarnessTestFilePlatform', () => {
11
+ it('returns null for shared harness test files', () => {
12
+ expect(getHarnessTestFilePlatform('/tests/smoke.harness.ts', knownPlatformIds)).toBeNull();
13
+ expect(getHarnessTestFilePlatform('/tests/smoke.harness.tsx', knownPlatformIds)).toBeNull();
14
+ expect(getHarnessTestFilePlatform('/tests/smoke.harness.mjs', knownPlatformIds)).toBeNull();
15
+ });
16
+
17
+ it('returns the platform id for platform-specific harness test files', () => {
18
+ expect(
19
+ getHarnessTestFilePlatform('/tests/kotlin.android.harness.ts', knownPlatformIds),
20
+ ).toBe('android');
21
+ expect(
22
+ getHarnessTestFilePlatform('/tests/swift.ios.harness.ts', knownPlatformIds),
23
+ ).toBe('ios');
24
+ expect(
25
+ getHarnessTestFilePlatform('/tests/browser.web.harness.ts', knownPlatformIds),
26
+ ).toBe('web');
27
+ });
28
+
29
+ it('returns null when the segment before .harness is not a known platform id', () => {
30
+ expect(
31
+ getHarnessTestFilePlatform('/tests/smoke.harness.ts', knownPlatformIds),
32
+ ).toBeNull();
33
+ expect(
34
+ getHarnessTestFilePlatform('/tests/custom.foo.harness.ts', knownPlatformIds),
35
+ ).toBeNull();
36
+ });
37
+ });
38
+
39
+ describe('shouldRunHarnessTestFile', () => {
40
+ it('runs shared harness test files on every platform', () => {
41
+ expect(
42
+ shouldRunHarnessTestFile('/tests/smoke.harness.ts', 'android', knownPlatformIds),
43
+ ).toBe(true);
44
+ expect(
45
+ shouldRunHarnessTestFile('/tests/smoke.harness.ts', 'ios', knownPlatformIds),
46
+ ).toBe(true);
47
+ });
48
+
49
+ it('runs platform-specific files only on the matching platform', () => {
50
+ expect(
51
+ shouldRunHarnessTestFile('/tests/kotlin.android.harness.ts', 'android', knownPlatformIds),
52
+ ).toBe(true);
53
+ expect(
54
+ shouldRunHarnessTestFile('/tests/kotlin.android.harness.ts', 'ios', knownPlatformIds),
55
+ ).toBe(false);
56
+ expect(
57
+ shouldRunHarnessTestFile('/tests/swift.ios.harness.ts', 'ios', knownPlatformIds),
58
+ ).toBe(true);
59
+ expect(
60
+ shouldRunHarnessTestFile('/tests/swift.ios.harness.ts', 'android', knownPlatformIds),
61
+ ).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe('createPlatformSkippedTestResult', () => {
66
+ it('marks the entire test file as skipped', () => {
67
+ const result = createPlatformSkippedTestResult('/tests/swift.ios.harness.ts');
68
+
69
+ expect(result.skipped).toBe(true);
70
+ expect(result.numPassingTests).toBe(0);
71
+ expect(result.numFailingTests).toBe(0);
72
+ expect(result.numPendingTests).toBe(1);
73
+ expect(result.testResults).toHaveLength(1);
74
+ expect(result.testResults[0]).toEqual(
75
+ expect.objectContaining({
76
+ status: 'skipped',
77
+ title: 'swift.ios.harness.ts',
78
+ fullName: 'swift.ios.harness.ts',
79
+ }),
80
+ );
81
+ expect(result.testFilePath).toBe('/tests/swift.ios.harness.ts');
82
+ });
83
+ });
@@ -1,18 +1,24 @@
1
1
  import {
2
- type AppMonitor,
3
2
  type AppCrashDetails,
4
- type AppMonitorEvent,
5
- type AppMonitorListener,
6
- type HarnessPlatformRunner,
3
+ type AppSession,
4
+ type AppSessionEvent,
5
+ type AppSessionListener,
6
+ type AppSessionLog,
7
+ type AppSessionState,
7
8
  } from '@react-native-harness/platforms';
8
9
  import {
9
10
  NativeCrashError,
11
+ RuntimeDisconnectError,
12
+ type HarnessRuntimeFailure,
10
13
  type NativeCrashDetails,
11
14
  type NativeCrashPhase,
15
+ type RuntimeDisconnectDetails,
12
16
  } from './errors.js';
13
17
  import { logger } from '@react-native-harness/tools';
14
18
 
15
19
  const crashLogger = logger.child('crash');
20
+ const CRASH_CLASSIFICATION_SETTLE_MS = 1500;
21
+ const CRASH_LOG_WINDOW_MS = 3000;
16
22
 
17
23
  export class CrashWatchCancelledError extends Error {
18
24
  constructor() {
@@ -32,172 +38,228 @@ export type CrashMonitor = {
32
38
  stop: () => Promise<void>;
33
39
  start: () => Promise<void>;
34
40
  reset: () => void;
41
+ setAppSession: (session: AppSession | null) => void;
42
+ handleBridgeDisconnect: () => void;
43
+ /**
44
+ * Opens a window during which handleBridgeDisconnect() is suppressed,
45
+ * for use around operations (like a runtime reset) that intentionally
46
+ * disconnect the bridge without the app process dying. Returns a closer
47
+ * that ends the window; safe to call multiple times or nest.
48
+ */
49
+ expectDisconnect: () => () => void;
35
50
  dispose: () => Promise<void>;
36
51
  };
37
52
 
38
53
  export type CrashMonitorOptions = {
39
- appMonitor: AppMonitor;
40
- platformRunner: HarnessPlatformRunner;
54
+ appSession?: AppSession | null;
41
55
  };
42
56
 
43
- type CrashDetailsProvider = {
44
- getCrashDetails?: (options: {
45
- processName?: string;
46
- pid?: number;
47
- occurredAt: number;
48
- }) => Promise<AppCrashDetails | null>;
57
+ type PendingCrash = {
58
+ testFilePath: string;
59
+ phase: NativeCrashPhase;
60
+ occurredAt: number;
49
61
  };
50
62
 
51
- const mergeCrashDetails = (
63
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
64
+
65
+ const isCrashIndicator = (line: string) =>
66
+ /uncaught exception|terminating app due to|fatal error|EXC_[A-Z_]+|termination reason|crash|abort/i.test(
67
+ line
68
+ ) || /\bSIG[A-Z]{2,}\b/.test(line);
69
+
70
+ const getMatchingCrashLines = (
71
+ logs: AppSessionLog[],
72
+ occurredAt: number
73
+ ): string[] =>
74
+ logs
75
+ .filter(
76
+ (log) => Math.abs(log.occurredAt - occurredAt) <= CRASH_LOG_WINDOW_MS
77
+ )
78
+ .map((log) => log.line)
79
+ .filter(isCrashIndicator);
80
+
81
+ const buildNativeCrashDetails = (
52
82
  phase: NativeCrashPhase,
53
- initial?: AppCrashDetails,
54
- enriched?: AppCrashDetails | null,
55
- fallbackSummary?: string,
83
+ rawLines: string[],
84
+ summary: string
56
85
  ): NativeCrashDetails => ({
57
86
  phase,
58
- source: enriched?.source ?? initial?.source,
59
- summary: enriched?.summary ?? initial?.summary ?? fallbackSummary,
60
- signal: enriched?.signal ?? initial?.signal,
61
- exceptionType: enriched?.exceptionType ?? initial?.exceptionType,
62
- processName: enriched?.processName ?? initial?.processName,
63
- pid: enriched?.pid ?? initial?.pid,
64
- stackTrace: enriched?.stackTrace ?? initial?.stackTrace,
65
- rawLines: enriched?.rawLines ?? initial?.rawLines,
66
- artifactType: enriched?.artifactType ?? initial?.artifactType,
67
- artifactPath: enriched?.artifactPath ?? initial?.artifactPath,
87
+ source: rawLines.length > 0 ? 'logs' : 'bridge',
88
+ summary: rawLines.length > 0 ? rawLines.join('\n') : summary,
89
+ rawLines: rawLines.length > 0 ? rawLines : undefined,
90
+ });
91
+
92
+ const getStatePid = (state: AppSessionState | undefined) => {
93
+ if (state && 'pid' in state) {
94
+ return state.pid;
95
+ }
96
+
97
+ return undefined;
98
+ };
99
+
100
+ const mergeCrashDetails = (
101
+ fallback: NativeCrashDetails,
102
+ extracted: AppCrashDetails | null
103
+ ): NativeCrashDetails => {
104
+ if (!extracted) {
105
+ return fallback;
106
+ }
107
+
108
+ return {
109
+ ...fallback,
110
+ ...extracted,
111
+ phase: fallback.phase,
112
+ source: extracted.source ?? fallback.source,
113
+ summary: extracted.summary ?? fallback.summary,
114
+ rawLines: extracted.rawLines ?? fallback.rawLines,
115
+ };
116
+ };
117
+
118
+ const buildRuntimeDisconnectDetails = (
119
+ phase: NativeCrashPhase,
120
+ rawLines: string[]
121
+ ): RuntimeDisconnectDetails => ({
122
+ phase,
123
+ source: 'bridge',
124
+ summary:
125
+ 'The runtime bridge disconnected, but the app session still appears to be running.',
126
+ rawLines: rawLines.length > 0 ? rawLines : undefined,
68
127
  });
69
128
 
70
129
  export const createCrashMonitor = ({
71
- appMonitor,
72
- platformRunner,
73
- }: CrashMonitorOptions): CrashMonitor => {
130
+ appSession: initialAppSession = null,
131
+ }: CrashMonitorOptions = {}): CrashMonitor => {
74
132
  let alive = false;
75
133
  let monitoring = true;
76
134
  let isResolvingCrash = false;
77
135
  let disposed = false;
136
+ let appSession: AppSession | null = null;
137
+ let pendingTimer: NodeJS.Timeout | null = null;
78
138
 
79
- // Both updated when watch() is called so crashes are attributed to the
80
- // correct test file and lifecycle phase.
81
139
  let currentTestFilePath = '';
82
140
  let currentPhase: NativeCrashPhase = 'startup';
83
- const watchers = new Set<(err: NativeCrashError) => void>();
84
-
85
- const getCrashDetailsProvider = (): CrashDetailsProvider | null => {
86
- if ('getCrashDetails' in appMonitor) {
87
- return appMonitor as AppMonitor & CrashDetailsProvider;
88
- }
89
- if (platformRunner.getCrashDetails) {
90
- return platformRunner;
91
- }
92
- return null;
93
- };
141
+ let expectedDisconnectDepth = 0;
142
+ const watchers = new Set<(err: HarnessRuntimeFailure) => void>();
94
143
 
95
- const notifyCrash = (err: NativeCrashError) => {
144
+ const notifyFailure = (err: HarnessRuntimeFailure) => {
96
145
  const pending = [...watchers];
97
146
  watchers.clear();
98
147
  for (const fn of pending) fn(err);
99
148
  };
100
149
 
101
- const handleCrash = async (
102
- phase: NativeCrashPhase,
103
- details?: AppCrashDetails,
104
- fallbackSummary?: string,
105
- ) => {
106
- if (isResolvingCrash) return;
107
- isResolvingCrash = true;
108
- alive = false;
109
-
110
- crashLogger.debug('native crash detected (phase=%s)', phase);
111
- for (const line of details?.rawLines ?? []) {
112
- crashLogger.debug('%s', line);
113
- }
114
-
115
- try {
116
- const enriched = await getCrashDetailsProvider()?.getCrashDetails?.({
117
- processName: details?.processName,
118
- pid: details?.pid,
119
- occurredAt: Date.now(),
120
- });
121
- const merged = mergeCrashDetails(phase, details, enriched, fallbackSummary);
122
- crashLogger.debug('crash details: %o', {
123
- phase: merged.phase,
124
- source: merged.source,
125
- summary: merged.summary,
126
- signal: merged.signal,
127
- exceptionType: merged.exceptionType,
128
- processName: merged.processName,
129
- pid: merged.pid,
130
- });
131
- notifyCrash(new NativeCrashError(currentTestFilePath, merged));
132
- } finally {
133
- isResolvingCrash = false;
150
+ const clearPendingTimer = () => {
151
+ if (pendingTimer) {
152
+ clearTimeout(pendingTimer);
153
+ pendingTimer = null;
134
154
  }
135
155
  };
136
156
 
137
- const confirmAndHandleCrash = async (
138
- phase: NativeCrashPhase,
139
- details?: AppCrashDetails,
140
- fallbackSummary?: string,
157
+ const classify = async (
158
+ pending: PendingCrash,
159
+ trigger: 'bridge-disconnect' | 'app-exit'
141
160
  ) => {
142
- if (disposed || !monitoring) return;
161
+ if (disposed || !monitoring || isResolvingCrash) {
162
+ return;
163
+ }
164
+
165
+ isResolvingCrash = true;
166
+
143
167
  try {
144
- const isRunning = await platformRunner.isAppRunning();
145
- if (!isRunning) {
146
- void handleCrash(phase, details, fallbackSummary);
168
+ const session = appSession;
169
+ const state = await session?.getState();
170
+ const logs = session?.getLogs() ?? [];
171
+ const rawLines = getMatchingCrashLines(logs, pending.occurredAt);
172
+
173
+ if (state?.status === 'running' && trigger === 'bridge-disconnect') {
174
+ crashLogger.debug(
175
+ 'runtime bridge disconnected without confirmed app death'
176
+ );
177
+ notifyFailure(
178
+ new RuntimeDisconnectError(
179
+ pending.testFilePath,
180
+ buildRuntimeDisconnectDetails(pending.phase, rawLines)
181
+ )
182
+ );
183
+ return;
147
184
  }
148
- } catch (error) {
149
- crashLogger.debug('crash confirmation failed', error);
185
+
186
+ alive = false;
187
+ crashLogger.debug('native crash detected (phase=%s)', pending.phase);
188
+ for (const line of rawLines) {
189
+ crashLogger.debug('%s', line);
190
+ }
191
+
192
+ const fallbackSummary =
193
+ trigger === 'bridge-disconnect'
194
+ ? 'The app process exited after the runtime bridge disconnected, but no crash log lines were found.'
195
+ : 'The app process exited, but no crash log lines were found.';
196
+ const details = buildNativeCrashDetails(
197
+ pending.phase,
198
+ rawLines,
199
+ fallbackSummary
200
+ );
201
+ const extractedDetails = session?.getCrashDetails
202
+ ? await session
203
+ .getCrashDetails({
204
+ occurredAt: pending.occurredAt,
205
+ pid: getStatePid(state),
206
+ processName: details.processName,
207
+ testFilePath: pending.testFilePath,
208
+ })
209
+ .catch((error) => {
210
+ crashLogger.warn(
211
+ 'failed to extract native crash details: %s',
212
+ error
213
+ );
214
+ return null;
215
+ })
216
+ : null;
217
+ notifyFailure(
218
+ new NativeCrashError(
219
+ pending.testFilePath,
220
+ mergeCrashDetails(details, extractedDetails)
221
+ )
222
+ );
223
+ } finally {
224
+ isResolvingCrash = false;
225
+ pendingTimer = null;
150
226
  }
151
227
  };
152
228
 
153
- const extractCrashDetails = (
154
- event: Extract<AppMonitorEvent, { type: 'app_exited' | 'possible_crash' }>,
155
- ): AppCrashDetails | undefined =>
156
- event.crashDetails
157
- ? {
158
- source: event.crashDetails.source ?? event.source,
159
- summary: event.crashDetails.summary,
160
- signal: event.crashDetails.signal,
161
- exceptionType: event.crashDetails.exceptionType,
162
- processName: event.crashDetails.processName,
163
- pid: event.crashDetails.pid ?? event.pid,
164
- stackTrace: event.crashDetails.stackTrace,
165
- rawLines:
166
- event.crashDetails.rawLines ??
167
- (event.line ? [event.line] : undefined),
168
- }
169
- : undefined;
170
-
171
- const appMonitorListener: AppMonitorListener = (event: AppMonitorEvent) => {
172
- if (disposed || !monitoring) return;
173
-
174
- if (event.type === 'app_started') {
175
- alive = true;
229
+ const startCrashResolution = (trigger: 'bridge-disconnect' | 'app-exit') => {
230
+ if (disposed || !monitoring || isResolvingCrash) {
176
231
  return;
177
232
  }
178
233
 
234
+ clearPendingTimer();
235
+ const pending: PendingCrash = {
236
+ testFilePath: currentTestFilePath,
237
+ phase: currentPhase,
238
+ occurredAt: Date.now(),
239
+ };
240
+
241
+ pendingTimer = setTimeout(
242
+ () => {
243
+ void classify(pending, trigger);
244
+ },
245
+ trigger === 'bridge-disconnect' ? CRASH_CLASSIFICATION_SETTLE_MS : 0
246
+ );
247
+ };
248
+
249
+ const appSessionListener: AppSessionListener = (event: AppSessionEvent) => {
179
250
  if (event.type === 'app_exited') {
180
- const details = extractCrashDetails(event);
181
- if (event.isConfirmed ?? event.source === 'polling') {
182
- void handleCrash(currentPhase, details);
183
- } else {
184
- void confirmAndHandleCrash(currentPhase, details);
185
- }
186
- return;
251
+ startCrashResolution('app-exit');
187
252
  }
253
+ };
188
254
 
189
- if (event.type === 'possible_crash') {
190
- const details = extractCrashDetails(event);
191
- const fallback = `possible crash signal (${event.source ?? 'unknown'})`;
192
- if (event.isConfirmed) {
193
- void handleCrash(currentPhase, details, fallback);
194
- } else {
195
- void confirmAndHandleCrash(currentPhase, details, fallback);
196
- }
197
- }
255
+ const setAppSession = (session: AppSession | null) => {
256
+ appSession?.removeListener(appSessionListener);
257
+ appSession = session;
258
+ alive = Boolean(session);
259
+ session?.addListener(appSessionListener);
198
260
  };
199
261
 
200
- appMonitor.addListener(appMonitorListener);
262
+ setAppSession(initialAppSession);
201
263
 
202
264
  const watch = (testFilePath: string, phase: NativeCrashPhase): CrashWatch => {
203
265
  currentTestFilePath = testFilePath;
@@ -219,30 +281,57 @@ export const createCrashMonitor = ({
219
281
  return { promise, cancel };
220
282
  };
221
283
 
284
+ const expectDisconnect = (): (() => void) => {
285
+ expectedDisconnectDepth += 1;
286
+ let closed = false;
287
+
288
+ return () => {
289
+ if (closed) {
290
+ return;
291
+ }
292
+ closed = true;
293
+ expectedDisconnectDepth = Math.max(0, expectedDisconnectDepth - 1);
294
+ };
295
+ };
296
+
222
297
  return {
223
298
  watch,
224
299
  isAlive: () => alive,
225
300
  stop: async () => {
226
301
  monitoring = false;
227
- await appMonitor.stop();
302
+ clearPendingTimer();
303
+ await sleep(0);
228
304
  },
229
305
  start: async () => {
230
306
  monitoring = true;
231
- await appMonitor.start();
232
307
  },
233
308
  reset: () => {
234
- alive = false;
309
+ alive = Boolean(appSession);
235
310
  watchers.clear();
236
311
  isResolvingCrash = false;
237
312
  currentTestFilePath = '';
313
+ expectedDisconnectDepth = 0;
314
+ clearPendingTimer();
238
315
  },
316
+ setAppSession,
317
+ handleBridgeDisconnect: () => {
318
+ if (expectedDisconnectDepth > 0) {
319
+ crashLogger.debug('ignoring expected bridge disconnect');
320
+ return;
321
+ }
322
+ startCrashResolution('bridge-disconnect');
323
+ },
324
+ expectDisconnect,
239
325
  dispose: async () => {
240
326
  disposed = true;
241
327
  monitoring = false;
328
+ expectedDisconnectDepth = 0;
242
329
  watchers.clear();
243
330
  isResolvingCrash = false;
244
- appMonitor.removeListener(appMonitorListener);
245
- await appMonitor.dispose();
331
+ clearPendingTimer();
332
+ appSession?.removeListener(appSessionListener);
333
+ appSession = null;
334
+ alive = false;
246
335
  },
247
336
  };
248
337
  };