@react-native-harness/jest 1.3.0 → 1.4.0-rc.1

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 (49) hide show
  1. package/dist/__tests__/bridge.test.js +23 -2
  2. package/dist/__tests__/crash-monitor.test.js +146 -192
  3. package/dist/__tests__/errors.test.js +39 -0
  4. package/dist/__tests__/execute-run.test.js +190 -26
  5. package/dist/__tests__/harness-session.test.js +63 -239
  6. package/dist/__tests__/index.test.js +59 -10
  7. package/dist/__tests__/test-file-platform-filter.test.d.ts +2 -0
  8. package/dist/__tests__/test-file-platform-filter.test.d.ts.map +1 -0
  9. package/dist/__tests__/test-file-platform-filter.test.js +47 -0
  10. package/dist/crash-monitor.d.ts +5 -4
  11. package/dist/crash-monitor.d.ts.map +1 -1
  12. package/dist/crash-monitor.js +116 -102
  13. package/dist/errors.d.ts +10 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +41 -2
  16. package/dist/execute-run.d.ts +4 -2
  17. package/dist/execute-run.d.ts.map +1 -1
  18. package/dist/execute-run.js +85 -7
  19. package/dist/harness-session.d.ts +21 -1
  20. package/dist/harness-session.d.ts.map +1 -1
  21. package/dist/harness-session.js +134 -39
  22. package/dist/index.d.ts +7 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +24 -2
  25. package/dist/run.d.ts.map +1 -1
  26. package/dist/run.js +1 -0
  27. package/dist/test-file-platform-filter.d.ts +5 -0
  28. package/dist/test-file-platform-filter.d.ts.map +1 -0
  29. package/dist/test-file-platform-filter.js +40 -0
  30. package/dist/toTestResult.d.ts +1 -0
  31. package/dist/toTestResult.d.ts.map +1 -1
  32. package/dist/toTestResult.js +2 -2
  33. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  34. package/package.json +7 -7
  35. package/src/__tests__/bridge.test.ts +37 -2
  36. package/src/__tests__/crash-monitor.test.ts +182 -231
  37. package/src/__tests__/errors.test.ts +75 -0
  38. package/src/__tests__/execute-run.test.ts +235 -34
  39. package/src/__tests__/harness-session.test.ts +93 -0
  40. package/src/__tests__/index.test.ts +89 -11
  41. package/src/__tests__/test-file-platform-filter.test.ts +83 -0
  42. package/src/crash-monitor.ts +192 -131
  43. package/src/errors.ts +62 -1
  44. package/src/execute-run.ts +125 -11
  45. package/src/harness-session.ts +177 -42
  46. package/src/index.ts +40 -11
  47. package/src/run.ts +1 -0
  48. package/src/test-file-platform-filter.ts +57 -0
  49. package/src/toTestResult.ts +3 -2
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it, vi, beforeEach } from 'vitest';
2
- import type { Config, Test, TestRunnerOptions, TestWatcher } from 'jest-runner';
2
+ import type { Config, Test, TestEvents, TestRunnerOptions, TestWatcher } from 'jest-runner';
3
3
  import type { TestSuiteResult } from '@react-native-harness/bridge';
4
4
  import type { HarnessSession } from '../harness-session.js';
5
+ import type { executeRun as ExecuteRun } from '../execute-run.js';
5
6
  import { HarnessError } from '@react-native-harness/tools';
6
7
  import JestHarness from '../index.js';
7
8
 
@@ -14,6 +15,7 @@ const resolveUndefined = async () => undefined;
14
15
  const mockSession: HarnessSession = {
15
16
  config: { metroPort: 8081 } as HarnessSession['config'],
16
17
  context: {} as HarnessSession['context'],
18
+ onTestRunnerEvent: vi.fn(() => () => undefined),
17
19
  ensureAppReady: vi.fn(resolveUndefined),
18
20
  runTestFile: vi.fn(async (): Promise<TestSuiteResult> => ({
19
21
  name: '',
@@ -55,6 +57,41 @@ const makeTest = (): Test => ({
55
57
  const makeGlobalConfig = (overrides: Partial<Config.GlobalConfig> = {}): Config.GlobalConfig =>
56
58
  ({ rootDir: '/project', watch: false, watchAll: false, collectCoverage: false, ...overrides } as Config.GlobalConfig);
57
59
 
60
+ type ExecuteRunMock = typeof ExecuteRun;
61
+ type TypedExecuteRunMock = typeof mockExecuteRun & {
62
+ mockImplementationOnce: (implementation: ExecuteRunMock) => typeof mockExecuteRun;
63
+ mockResolvedValue: (value: Awaited<ReturnType<ExecuteRunMock>>) => typeof mockExecuteRun;
64
+ };
65
+ type ExecuteRunParams = Parameters<ExecuteRunMock>;
66
+
67
+ const typedMockExecuteRun = mockExecuteRun as TypedExecuteRunMock;
68
+
69
+ const makeTestCaseStartPayload = (): TestEvents['test-case-start'] => [
70
+ '/test/example.ts',
71
+ {
72
+ ancestorTitles: [],
73
+ fullName: 'suite case',
74
+ mode: undefined,
75
+ title: 'case',
76
+ startedAt: 1,
77
+ },
78
+ ];
79
+
80
+ const makeTestCaseResultPayload = (): TestEvents['test-case-result'] => [
81
+ '/test/example.ts',
82
+ {
83
+ ancestorTitles: [],
84
+ duration: 1,
85
+ failureDetails: [],
86
+ failureMessages: [],
87
+ fullName: 'suite case',
88
+ numPassingAsserts: 1,
89
+ startedAt: 1,
90
+ status: 'passed',
91
+ title: 'case',
92
+ },
93
+ ];
94
+
58
95
  // ---------------------------------------------------------------------------
59
96
  // Tests
60
97
  // ---------------------------------------------------------------------------
@@ -62,7 +99,7 @@ const makeGlobalConfig = (overrides: Partial<Config.GlobalConfig> = {}): Config.
62
99
  beforeEach(() => {
63
100
  vi.clearAllMocks();
64
101
  (mockSession.dispose as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
65
- mockExecuteRun.mockResolvedValue(undefined);
102
+ typedMockExecuteRun.mockResolvedValue(undefined);
66
103
  mockCreateHarnessSession.mockResolvedValue(mockSession);
67
104
  });
68
105
 
@@ -71,7 +108,7 @@ describe('JestHarness', () => {
71
108
  it('creates a session on the first run', async () => {
72
109
  const runner = new JestHarness(makeGlobalConfig());
73
110
 
74
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
111
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
75
112
 
76
113
  expect(mockCreateHarnessSession).toHaveBeenCalledOnce();
77
114
  });
@@ -79,8 +116,8 @@ describe('JestHarness', () => {
79
116
  it('reuses the session across runs in watch mode', async () => {
80
117
  const runner = new JestHarness(makeGlobalConfig({ watch: true }));
81
118
 
82
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
83
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
119
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
120
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
84
121
 
85
122
  expect(mockCreateHarnessSession).toHaveBeenCalledOnce();
86
123
  expect(mockSession.dispose).not.toHaveBeenCalled();
@@ -89,7 +126,7 @@ describe('JestHarness', () => {
89
126
  it('disposes the session after each run in normal mode', async () => {
90
127
  const runner = new JestHarness(makeGlobalConfig());
91
128
 
92
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
129
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
93
130
 
94
131
  expect(mockSession.dispose).toHaveBeenCalledOnce();
95
132
  });
@@ -97,11 +134,52 @@ describe('JestHarness', () => {
97
134
  it('creates a fresh session for each run in normal mode', async () => {
98
135
  const runner = new JestHarness(makeGlobalConfig());
99
136
 
100
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
101
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
137
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
138
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
102
139
 
103
140
  expect(mockCreateHarnessSession).toHaveBeenCalledTimes(2);
104
141
  });
142
+
143
+ it('emits registered Jest events', async () => {
144
+ const runner = new JestHarness(makeGlobalConfig());
145
+ const listener = vi.fn();
146
+ const eventPayload = makeTestCaseStartPayload();
147
+
148
+ runner.on('test-case-start', listener);
149
+ typedMockExecuteRun.mockImplementationOnce(async (
150
+ _session: ExecuteRunParams[0],
151
+ _tests: ExecuteRunParams[1],
152
+ _watcher: ExecuteRunParams[2],
153
+ emitEvent: ExecuteRunParams[3],
154
+ ) => {
155
+ await emitEvent('test-case-start', ...eventPayload);
156
+ });
157
+
158
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
159
+
160
+ expect(listener).toHaveBeenCalledWith(eventPayload);
161
+ });
162
+
163
+ it('stops emitting after listener unsubscribe', async () => {
164
+ const runner = new JestHarness(makeGlobalConfig());
165
+ const listener = vi.fn();
166
+ const unsubscribe = runner.on('test-case-result', listener);
167
+ const eventPayload = makeTestCaseResultPayload();
168
+
169
+ unsubscribe();
170
+ typedMockExecuteRun.mockImplementationOnce(async (
171
+ _session: ExecuteRunParams[0],
172
+ _tests: ExecuteRunParams[1],
173
+ _watcher: ExecuteRunParams[2],
174
+ emitEvent: ExecuteRunParams[3],
175
+ ) => {
176
+ await emitEvent('test-case-result', ...eventPayload);
177
+ });
178
+
179
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
180
+
181
+ expect(listener).not.toHaveBeenCalled();
182
+ });
105
183
  });
106
184
 
107
185
  describe('error handling', () => {
@@ -115,7 +193,7 @@ describe('JestHarness', () => {
115
193
  const runner = new JestHarness(makeGlobalConfig());
116
194
 
117
195
  await expect(
118
- runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions()),
196
+ runner.runTests([makeTest()], makeWatcher(), makeOptions()),
119
197
  ).rejects.toBeTypeOf('string');
120
198
  });
121
199
 
@@ -126,7 +204,7 @@ describe('JestHarness', () => {
126
204
  const runner = new JestHarness(makeGlobalConfig());
127
205
 
128
206
  await expect(
129
- runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions()),
207
+ runner.runTests([makeTest()], makeWatcher(), makeOptions()),
130
208
  ).rejects.toBe(cause);
131
209
  });
132
210
 
@@ -134,7 +212,7 @@ describe('JestHarness', () => {
134
212
  const runner = new JestHarness(makeGlobalConfig());
135
213
 
136
214
  await expect(
137
- runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), { serial: false }),
215
+ runner.runTests([makeTest()], makeWatcher(), { serial: false }),
138
216
  ).rejects.toThrow('Parallel test running is not supported');
139
217
  });
140
218
  });
@@ -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,220 @@ 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;
35
43
  dispose: () => Promise<void>;
36
44
  };
37
45
 
38
46
  export type CrashMonitorOptions = {
39
- appMonitor: AppMonitor;
40
- platformRunner: HarnessPlatformRunner;
47
+ appSession?: AppSession | null;
41
48
  };
42
49
 
43
- type CrashDetailsProvider = {
44
- getCrashDetails?: (options: {
45
- processName?: string;
46
- pid?: number;
47
- occurredAt: number;
48
- }) => Promise<AppCrashDetails | null>;
50
+ type PendingCrash = {
51
+ testFilePath: string;
52
+ phase: NativeCrashPhase;
53
+ occurredAt: number;
49
54
  };
50
55
 
51
- const mergeCrashDetails = (
56
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
57
+
58
+ const isCrashIndicator = (line: string) =>
59
+ /uncaught exception|terminating app due to|fatal error|EXC_[A-Z_]+|termination reason|crash|abort/i.test(
60
+ line
61
+ ) || /\bSIG[A-Z]{2,}\b/.test(line);
62
+
63
+ const getMatchingCrashLines = (
64
+ logs: AppSessionLog[],
65
+ occurredAt: number
66
+ ): string[] =>
67
+ logs
68
+ .filter(
69
+ (log) => Math.abs(log.occurredAt - occurredAt) <= CRASH_LOG_WINDOW_MS
70
+ )
71
+ .map((log) => log.line)
72
+ .filter(isCrashIndicator);
73
+
74
+ const buildNativeCrashDetails = (
52
75
  phase: NativeCrashPhase,
53
- initial?: AppCrashDetails,
54
- enriched?: AppCrashDetails | null,
55
- fallbackSummary?: string,
76
+ rawLines: string[],
77
+ summary: string
56
78
  ): NativeCrashDetails => ({
57
79
  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,
80
+ source: rawLines.length > 0 ? 'logs' : 'bridge',
81
+ summary: rawLines.length > 0 ? rawLines.join('\n') : summary,
82
+ rawLines: rawLines.length > 0 ? rawLines : undefined,
83
+ });
84
+
85
+ const getStatePid = (state: AppSessionState | undefined) => {
86
+ if (state && 'pid' in state) {
87
+ return state.pid;
88
+ }
89
+
90
+ return undefined;
91
+ };
92
+
93
+ const mergeCrashDetails = (
94
+ fallback: NativeCrashDetails,
95
+ extracted: AppCrashDetails | null
96
+ ): NativeCrashDetails => {
97
+ if (!extracted) {
98
+ return fallback;
99
+ }
100
+
101
+ return {
102
+ ...fallback,
103
+ ...extracted,
104
+ phase: fallback.phase,
105
+ source: extracted.source ?? fallback.source,
106
+ summary: extracted.summary ?? fallback.summary,
107
+ rawLines: extracted.rawLines ?? fallback.rawLines,
108
+ };
109
+ };
110
+
111
+ const buildRuntimeDisconnectDetails = (
112
+ phase: NativeCrashPhase,
113
+ rawLines: string[]
114
+ ): RuntimeDisconnectDetails => ({
115
+ phase,
116
+ source: 'bridge',
117
+ summary:
118
+ 'The runtime bridge disconnected, but the app session still appears to be running.',
119
+ rawLines: rawLines.length > 0 ? rawLines : undefined,
68
120
  });
69
121
 
70
122
  export const createCrashMonitor = ({
71
- appMonitor,
72
- platformRunner,
73
- }: CrashMonitorOptions): CrashMonitor => {
123
+ appSession: initialAppSession = null,
124
+ }: CrashMonitorOptions = {}): CrashMonitor => {
74
125
  let alive = false;
75
126
  let monitoring = true;
76
127
  let isResolvingCrash = false;
77
128
  let disposed = false;
129
+ let appSession: AppSession | null = null;
130
+ let pendingTimer: NodeJS.Timeout | null = null;
78
131
 
79
- // Both updated when watch() is called so crashes are attributed to the
80
- // correct test file and lifecycle phase.
81
132
  let currentTestFilePath = '';
82
133
  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
- };
134
+ const watchers = new Set<(err: HarnessRuntimeFailure) => void>();
94
135
 
95
- const notifyCrash = (err: NativeCrashError) => {
136
+ const notifyFailure = (err: HarnessRuntimeFailure) => {
96
137
  const pending = [...watchers];
97
138
  watchers.clear();
98
139
  for (const fn of pending) fn(err);
99
140
  };
100
141
 
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;
142
+ const clearPendingTimer = () => {
143
+ if (pendingTimer) {
144
+ clearTimeout(pendingTimer);
145
+ pendingTimer = null;
134
146
  }
135
147
  };
136
148
 
137
- const confirmAndHandleCrash = async (
138
- phase: NativeCrashPhase,
139
- details?: AppCrashDetails,
140
- fallbackSummary?: string,
149
+ const classify = async (
150
+ pending: PendingCrash,
151
+ trigger: 'bridge-disconnect' | 'app-exit'
141
152
  ) => {
142
- if (disposed || !monitoring) return;
153
+ if (disposed || !monitoring || isResolvingCrash) {
154
+ return;
155
+ }
156
+
157
+ isResolvingCrash = true;
158
+
143
159
  try {
144
- const isRunning = await platformRunner.isAppRunning();
145
- if (!isRunning) {
146
- void handleCrash(phase, details, fallbackSummary);
160
+ const session = appSession;
161
+ const state = await session?.getState();
162
+ const logs = session?.getLogs() ?? [];
163
+ const rawLines = getMatchingCrashLines(logs, pending.occurredAt);
164
+
165
+ if (state?.status === 'running' && trigger === 'bridge-disconnect') {
166
+ crashLogger.debug(
167
+ 'runtime bridge disconnected without confirmed app death'
168
+ );
169
+ notifyFailure(
170
+ new RuntimeDisconnectError(
171
+ pending.testFilePath,
172
+ buildRuntimeDisconnectDetails(pending.phase, rawLines)
173
+ )
174
+ );
175
+ return;
147
176
  }
148
- } catch (error) {
149
- crashLogger.debug('crash confirmation failed', error);
177
+
178
+ alive = false;
179
+ crashLogger.debug('native crash detected (phase=%s)', pending.phase);
180
+ for (const line of rawLines) {
181
+ crashLogger.debug('%s', line);
182
+ }
183
+
184
+ const fallbackSummary =
185
+ trigger === 'bridge-disconnect'
186
+ ? 'The app process exited after the runtime bridge disconnected, but no crash log lines were found.'
187
+ : 'The app process exited, but no crash log lines were found.';
188
+ const details = buildNativeCrashDetails(
189
+ pending.phase,
190
+ rawLines,
191
+ fallbackSummary
192
+ );
193
+ const extractedDetails = session?.getCrashDetails
194
+ ? await session
195
+ .getCrashDetails({
196
+ occurredAt: pending.occurredAt,
197
+ pid: getStatePid(state),
198
+ processName: details.processName,
199
+ testFilePath: pending.testFilePath,
200
+ })
201
+ .catch((error) => {
202
+ crashLogger.warn(
203
+ 'failed to extract native crash details: %s',
204
+ error
205
+ );
206
+ return null;
207
+ })
208
+ : null;
209
+ notifyFailure(
210
+ new NativeCrashError(
211
+ pending.testFilePath,
212
+ mergeCrashDetails(details, extractedDetails)
213
+ )
214
+ );
215
+ } finally {
216
+ isResolvingCrash = false;
217
+ pendingTimer = null;
150
218
  }
151
219
  };
152
220
 
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;
221
+ const startCrashResolution = (trigger: 'bridge-disconnect' | 'app-exit') => {
222
+ if (disposed || !monitoring || isResolvingCrash) {
176
223
  return;
177
224
  }
178
225
 
226
+ clearPendingTimer();
227
+ const pending: PendingCrash = {
228
+ testFilePath: currentTestFilePath,
229
+ phase: currentPhase,
230
+ occurredAt: Date.now(),
231
+ };
232
+
233
+ pendingTimer = setTimeout(
234
+ () => {
235
+ void classify(pending, trigger);
236
+ },
237
+ trigger === 'bridge-disconnect' ? CRASH_CLASSIFICATION_SETTLE_MS : 0
238
+ );
239
+ };
240
+
241
+ const appSessionListener: AppSessionListener = (event: AppSessionEvent) => {
179
242
  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;
243
+ startCrashResolution('app-exit');
187
244
  }
245
+ };
188
246
 
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
- }
247
+ const setAppSession = (session: AppSession | null) => {
248
+ appSession?.removeListener(appSessionListener);
249
+ appSession = session;
250
+ alive = Boolean(session);
251
+ session?.addListener(appSessionListener);
198
252
  };
199
253
 
200
- appMonitor.addListener(appMonitorListener);
254
+ setAppSession(initialAppSession);
201
255
 
202
256
  const watch = (testFilePath: string, phase: NativeCrashPhase): CrashWatch => {
203
257
  currentTestFilePath = testFilePath;
@@ -224,25 +278,32 @@ export const createCrashMonitor = ({
224
278
  isAlive: () => alive,
225
279
  stop: async () => {
226
280
  monitoring = false;
227
- await appMonitor.stop();
281
+ clearPendingTimer();
282
+ await sleep(0);
228
283
  },
229
284
  start: async () => {
230
285
  monitoring = true;
231
- await appMonitor.start();
232
286
  },
233
287
  reset: () => {
234
- alive = false;
288
+ alive = Boolean(appSession);
235
289
  watchers.clear();
236
290
  isResolvingCrash = false;
237
291
  currentTestFilePath = '';
292
+ clearPendingTimer();
293
+ },
294
+ setAppSession,
295
+ handleBridgeDisconnect: () => {
296
+ startCrashResolution('bridge-disconnect');
238
297
  },
239
298
  dispose: async () => {
240
299
  disposed = true;
241
300
  monitoring = false;
242
301
  watchers.clear();
243
302
  isResolvingCrash = false;
244
- appMonitor.removeListener(appMonitorListener);
245
- await appMonitor.dispose();
303
+ clearPendingTimer();
304
+ appSession?.removeListener(appSessionListener);
305
+ appSession = null;
306
+ alive = false;
246
307
  },
247
308
  };
248
309
  };