@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
@@ -8,6 +8,7 @@ const resolveUndefined = async () => undefined;
8
8
  const mockSession = {
9
9
  config: { metroPort: 8081 },
10
10
  context: {},
11
+ onTestRunnerEvent: vi.fn(() => () => undefined),
11
12
  ensureAppReady: vi.fn(resolveUndefined),
12
13
  runTestFile: vi.fn(async () => ({
13
14
  name: '',
@@ -41,40 +42,88 @@ const makeTest = () => ({
41
42
  context: { config: {} },
42
43
  });
43
44
  const makeGlobalConfig = (overrides = {}) => ({ rootDir: '/project', watch: false, watchAll: false, collectCoverage: false, ...overrides });
45
+ const typedMockExecuteRun = mockExecuteRun;
46
+ const makeTestCaseStartPayload = () => [
47
+ '/test/example.ts',
48
+ {
49
+ ancestorTitles: [],
50
+ fullName: 'suite case',
51
+ mode: undefined,
52
+ title: 'case',
53
+ startedAt: 1,
54
+ },
55
+ ];
56
+ const makeTestCaseResultPayload = () => [
57
+ '/test/example.ts',
58
+ {
59
+ ancestorTitles: [],
60
+ duration: 1,
61
+ failureDetails: [],
62
+ failureMessages: [],
63
+ fullName: 'suite case',
64
+ numPassingAsserts: 1,
65
+ startedAt: 1,
66
+ status: 'passed',
67
+ title: 'case',
68
+ },
69
+ ];
44
70
  // ---------------------------------------------------------------------------
45
71
  // Tests
46
72
  // ---------------------------------------------------------------------------
47
73
  beforeEach(() => {
48
74
  vi.clearAllMocks();
49
75
  mockSession.dispose.mockResolvedValue(undefined);
50
- mockExecuteRun.mockResolvedValue(undefined);
76
+ typedMockExecuteRun.mockResolvedValue(undefined);
51
77
  mockCreateHarnessSession.mockResolvedValue(mockSession);
52
78
  });
53
79
  describe('JestHarness', () => {
54
80
  describe('session lifecycle', () => {
55
81
  it('creates a session on the first run', async () => {
56
82
  const runner = new JestHarness(makeGlobalConfig());
57
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
83
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
58
84
  expect(mockCreateHarnessSession).toHaveBeenCalledOnce();
59
85
  });
60
86
  it('reuses the session across runs in watch mode', async () => {
61
87
  const runner = new JestHarness(makeGlobalConfig({ watch: true }));
62
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
63
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
88
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
89
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
64
90
  expect(mockCreateHarnessSession).toHaveBeenCalledOnce();
65
91
  expect(mockSession.dispose).not.toHaveBeenCalled();
66
92
  });
67
93
  it('disposes the session after each run in normal mode', async () => {
68
94
  const runner = new JestHarness(makeGlobalConfig());
69
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
95
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
70
96
  expect(mockSession.dispose).toHaveBeenCalledOnce();
71
97
  });
72
98
  it('creates a fresh session for each run in normal mode', async () => {
73
99
  const runner = new JestHarness(makeGlobalConfig());
74
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
75
- await runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions());
100
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
101
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
76
102
  expect(mockCreateHarnessSession).toHaveBeenCalledTimes(2);
77
103
  });
104
+ it('emits registered Jest events', async () => {
105
+ const runner = new JestHarness(makeGlobalConfig());
106
+ const listener = vi.fn();
107
+ const eventPayload = makeTestCaseStartPayload();
108
+ runner.on('test-case-start', listener);
109
+ typedMockExecuteRun.mockImplementationOnce(async (_session, _tests, _watcher, emitEvent) => {
110
+ await emitEvent('test-case-start', ...eventPayload);
111
+ });
112
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
113
+ expect(listener).toHaveBeenCalledWith(eventPayload);
114
+ });
115
+ it('stops emitting after listener unsubscribe', async () => {
116
+ const runner = new JestHarness(makeGlobalConfig());
117
+ const listener = vi.fn();
118
+ const unsubscribe = runner.on('test-case-result', listener);
119
+ const eventPayload = makeTestCaseResultPayload();
120
+ unsubscribe();
121
+ typedMockExecuteRun.mockImplementationOnce(async (_session, _tests, _watcher, emitEvent) => {
122
+ await emitEvent('test-case-result', ...eventPayload);
123
+ });
124
+ await runner.runTests([makeTest()], makeWatcher(), makeOptions());
125
+ expect(listener).not.toHaveBeenCalled();
126
+ });
78
127
  });
79
128
  describe('error handling', () => {
80
129
  it('converts HarnessError into a formatted string before throwing', async () => {
@@ -83,17 +132,17 @@ describe('JestHarness', () => {
83
132
  }
84
133
  mockCreateHarnessSession.mockRejectedValue(new TestHarnessError());
85
134
  const runner = new JestHarness(makeGlobalConfig());
86
- await expect(runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions())).rejects.toBeTypeOf('string');
135
+ await expect(runner.runTests([makeTest()], makeWatcher(), makeOptions())).rejects.toBeTypeOf('string');
87
136
  });
88
137
  it('propagates non-HarnessError exceptions as-is', async () => {
89
138
  const cause = new TypeError('unexpected');
90
139
  mockCreateHarnessSession.mockRejectedValue(cause);
91
140
  const runner = new JestHarness(makeGlobalConfig());
92
- await expect(runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), makeOptions())).rejects.toBe(cause);
141
+ await expect(runner.runTests([makeTest()], makeWatcher(), makeOptions())).rejects.toBe(cause);
93
142
  });
94
143
  it('throws when called without serial flag', async () => {
95
144
  const runner = new JestHarness(makeGlobalConfig());
96
- await expect(runner.runTests([makeTest()], makeWatcher(), vi.fn(), vi.fn(), vi.fn(), { serial: false })).rejects.toThrow('Parallel test running is not supported');
145
+ await expect(runner.runTests([makeTest()], makeWatcher(), { serial: false })).rejects.toThrow('Parallel test running is not supported');
97
146
  });
98
147
  });
99
148
  });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=test-file-platform-filter.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-file-platform-filter.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/test-file-platform-filter.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createPlatformSkippedTestResult, getHarnessTestFilePlatform, shouldRunHarnessTestFile, } from '../test-file-platform-filter.js';
3
+ const knownPlatformIds = new Set(['android', 'ios', 'web', 'vega']);
4
+ describe('getHarnessTestFilePlatform', () => {
5
+ it('returns null for shared harness test files', () => {
6
+ expect(getHarnessTestFilePlatform('/tests/smoke.harness.ts', knownPlatformIds)).toBeNull();
7
+ expect(getHarnessTestFilePlatform('/tests/smoke.harness.tsx', knownPlatformIds)).toBeNull();
8
+ expect(getHarnessTestFilePlatform('/tests/smoke.harness.mjs', knownPlatformIds)).toBeNull();
9
+ });
10
+ it('returns the platform id for platform-specific harness test files', () => {
11
+ expect(getHarnessTestFilePlatform('/tests/kotlin.android.harness.ts', knownPlatformIds)).toBe('android');
12
+ expect(getHarnessTestFilePlatform('/tests/swift.ios.harness.ts', knownPlatformIds)).toBe('ios');
13
+ expect(getHarnessTestFilePlatform('/tests/browser.web.harness.ts', knownPlatformIds)).toBe('web');
14
+ });
15
+ it('returns null when the segment before .harness is not a known platform id', () => {
16
+ expect(getHarnessTestFilePlatform('/tests/smoke.harness.ts', knownPlatformIds)).toBeNull();
17
+ expect(getHarnessTestFilePlatform('/tests/custom.foo.harness.ts', knownPlatformIds)).toBeNull();
18
+ });
19
+ });
20
+ describe('shouldRunHarnessTestFile', () => {
21
+ it('runs shared harness test files on every platform', () => {
22
+ expect(shouldRunHarnessTestFile('/tests/smoke.harness.ts', 'android', knownPlatformIds)).toBe(true);
23
+ expect(shouldRunHarnessTestFile('/tests/smoke.harness.ts', 'ios', knownPlatformIds)).toBe(true);
24
+ });
25
+ it('runs platform-specific files only on the matching platform', () => {
26
+ expect(shouldRunHarnessTestFile('/tests/kotlin.android.harness.ts', 'android', knownPlatformIds)).toBe(true);
27
+ expect(shouldRunHarnessTestFile('/tests/kotlin.android.harness.ts', 'ios', knownPlatformIds)).toBe(false);
28
+ expect(shouldRunHarnessTestFile('/tests/swift.ios.harness.ts', 'ios', knownPlatformIds)).toBe(true);
29
+ expect(shouldRunHarnessTestFile('/tests/swift.ios.harness.ts', 'android', knownPlatformIds)).toBe(false);
30
+ });
31
+ });
32
+ describe('createPlatformSkippedTestResult', () => {
33
+ it('marks the entire test file as skipped', () => {
34
+ const result = createPlatformSkippedTestResult('/tests/swift.ios.harness.ts');
35
+ expect(result.skipped).toBe(true);
36
+ expect(result.numPassingTests).toBe(0);
37
+ expect(result.numFailingTests).toBe(0);
38
+ expect(result.numPendingTests).toBe(1);
39
+ expect(result.testResults).toHaveLength(1);
40
+ expect(result.testResults[0]).toEqual(expect.objectContaining({
41
+ status: 'skipped',
42
+ title: 'swift.ios.harness.ts',
43
+ fullName: 'swift.ios.harness.ts',
44
+ }));
45
+ expect(result.testFilePath).toBe('/tests/swift.ios.harness.ts');
46
+ });
47
+ });
@@ -1,4 +1,4 @@
1
- import { type AppMonitor, type HarnessPlatformRunner } from '@react-native-harness/platforms';
1
+ import { type AppSession } from '@react-native-harness/platforms';
2
2
  import { type NativeCrashPhase } from './errors.js';
3
3
  export declare class CrashWatchCancelledError extends Error {
4
4
  constructor();
@@ -13,11 +13,12 @@ export type CrashMonitor = {
13
13
  stop: () => Promise<void>;
14
14
  start: () => Promise<void>;
15
15
  reset: () => void;
16
+ setAppSession: (session: AppSession | null) => void;
17
+ handleBridgeDisconnect: () => void;
16
18
  dispose: () => Promise<void>;
17
19
  };
18
20
  export type CrashMonitorOptions = {
19
- appMonitor: AppMonitor;
20
- platformRunner: HarnessPlatformRunner;
21
+ appSession?: AppSession | null;
21
22
  };
22
- export declare const createCrashMonitor: ({ appMonitor, platformRunner, }: CrashMonitorOptions) => CrashMonitor;
23
+ export declare const createCrashMonitor: ({ appSession: initialAppSession, }?: CrashMonitorOptions) => CrashMonitor;
23
24
  //# sourceMappingURL=crash-monitor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"crash-monitor.d.ts","sourceRoot":"","sources":["../src/crash-monitor.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,UAAU,EAIf,KAAK,qBAAqB,EAC3B,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,aAAa,CAAC;AAKrB,qBAAa,wBAAyB,SAAQ,KAAK;;CAKlD;AAED,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IACjC,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,KAAK,UAAU,CAAC;IACrE,OAAO,EAAE,MAAM,OAAO,CAAC;IACvB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,UAAU,EAAE,UAAU,CAAC;IACvB,cAAc,EAAE,qBAAqB,CAAC;CACvC,CAAC;AA6BF,eAAO,MAAM,kBAAkB,GAAI,iCAGhC,mBAAmB,KAAG,YA+KxB,CAAC"}
1
+ {"version":3,"file":"crash-monitor.d.ts","sourceRoot":"","sources":["../src/crash-monitor.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,UAAU,EAKhB,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAKL,KAAK,gBAAgB,EAEtB,MAAM,aAAa,CAAC;AAOrB,qBAAa,wBAAyB,SAAQ,KAAK;;CAKlD;AAED,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IACjC,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,KAAK,UAAU,CAAC;IACrE,OAAO,EAAE,MAAM,OAAO,CAAC;IACvB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,aAAa,EAAE,CAAC,OAAO,EAAE,UAAU,GAAG,IAAI,KAAK,IAAI,CAAC;IACpD,sBAAsB,EAAE,MAAM,IAAI,CAAC;IACnC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,UAAU,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC;CAChC,CAAC;AA0EF,eAAO,MAAM,kBAAkB,GAAI,qCAEhC,mBAAwB,KAAG,YAyL7B,CAAC"}
@@ -1,136 +1,143 @@
1
- import { NativeCrashError, } from './errors.js';
1
+ import { NativeCrashError, RuntimeDisconnectError, } from './errors.js';
2
2
  import { logger } from '@react-native-harness/tools';
3
3
  const crashLogger = logger.child('crash');
4
+ const CRASH_CLASSIFICATION_SETTLE_MS = 1500;
5
+ const CRASH_LOG_WINDOW_MS = 3000;
4
6
  export class CrashWatchCancelledError extends Error {
5
7
  constructor() {
6
8
  super('Crash watch was cancelled');
7
9
  this.name = 'CrashWatchCancelledError';
8
10
  }
9
11
  }
10
- const mergeCrashDetails = (phase, initial, enriched, fallbackSummary) => ({
12
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
+ const isCrashIndicator = (line) => /uncaught exception|terminating app due to|fatal error|EXC_[A-Z_]+|termination reason|crash|abort/i.test(line) || /\bSIG[A-Z]{2,}\b/.test(line);
14
+ const getMatchingCrashLines = (logs, occurredAt) => logs
15
+ .filter((log) => Math.abs(log.occurredAt - occurredAt) <= CRASH_LOG_WINDOW_MS)
16
+ .map((log) => log.line)
17
+ .filter(isCrashIndicator);
18
+ const buildNativeCrashDetails = (phase, rawLines, summary) => ({
11
19
  phase,
12
- source: enriched?.source ?? initial?.source,
13
- summary: enriched?.summary ?? initial?.summary ?? fallbackSummary,
14
- signal: enriched?.signal ?? initial?.signal,
15
- exceptionType: enriched?.exceptionType ?? initial?.exceptionType,
16
- processName: enriched?.processName ?? initial?.processName,
17
- pid: enriched?.pid ?? initial?.pid,
18
- stackTrace: enriched?.stackTrace ?? initial?.stackTrace,
19
- rawLines: enriched?.rawLines ?? initial?.rawLines,
20
- artifactType: enriched?.artifactType ?? initial?.artifactType,
21
- artifactPath: enriched?.artifactPath ?? initial?.artifactPath,
20
+ source: rawLines.length > 0 ? 'logs' : 'bridge',
21
+ summary: rawLines.length > 0 ? rawLines.join('\n') : summary,
22
+ rawLines: rawLines.length > 0 ? rawLines : undefined,
22
23
  });
23
- export const createCrashMonitor = ({ appMonitor, platformRunner, }) => {
24
+ const getStatePid = (state) => {
25
+ if (state && 'pid' in state) {
26
+ return state.pid;
27
+ }
28
+ return undefined;
29
+ };
30
+ const mergeCrashDetails = (fallback, extracted) => {
31
+ if (!extracted) {
32
+ return fallback;
33
+ }
34
+ return {
35
+ ...fallback,
36
+ ...extracted,
37
+ phase: fallback.phase,
38
+ source: extracted.source ?? fallback.source,
39
+ summary: extracted.summary ?? fallback.summary,
40
+ rawLines: extracted.rawLines ?? fallback.rawLines,
41
+ };
42
+ };
43
+ const buildRuntimeDisconnectDetails = (phase, rawLines) => ({
44
+ phase,
45
+ source: 'bridge',
46
+ summary: 'The runtime bridge disconnected, but the app session still appears to be running.',
47
+ rawLines: rawLines.length > 0 ? rawLines : undefined,
48
+ });
49
+ export const createCrashMonitor = ({ appSession: initialAppSession = null, } = {}) => {
24
50
  let alive = false;
25
51
  let monitoring = true;
26
52
  let isResolvingCrash = false;
27
53
  let disposed = false;
28
- // Both updated when watch() is called so crashes are attributed to the
29
- // correct test file and lifecycle phase.
54
+ let appSession = null;
55
+ let pendingTimer = null;
30
56
  let currentTestFilePath = '';
31
57
  let currentPhase = 'startup';
32
58
  const watchers = new Set();
33
- const getCrashDetailsProvider = () => {
34
- if ('getCrashDetails' in appMonitor) {
35
- return appMonitor;
36
- }
37
- if (platformRunner.getCrashDetails) {
38
- return platformRunner;
39
- }
40
- return null;
41
- };
42
- const notifyCrash = (err) => {
59
+ const notifyFailure = (err) => {
43
60
  const pending = [...watchers];
44
61
  watchers.clear();
45
62
  for (const fn of pending)
46
63
  fn(err);
47
64
  };
48
- const handleCrash = async (phase, details, fallbackSummary) => {
49
- if (isResolvingCrash)
65
+ const clearPendingTimer = () => {
66
+ if (pendingTimer) {
67
+ clearTimeout(pendingTimer);
68
+ pendingTimer = null;
69
+ }
70
+ };
71
+ const classify = async (pending, trigger) => {
72
+ if (disposed || !monitoring || isResolvingCrash) {
50
73
  return;
51
- isResolvingCrash = true;
52
- alive = false;
53
- crashLogger.debug('native crash detected (phase=%s)', phase);
54
- for (const line of details?.rawLines ?? []) {
55
- crashLogger.debug('%s', line);
56
74
  }
75
+ isResolvingCrash = true;
57
76
  try {
58
- const enriched = await getCrashDetailsProvider()?.getCrashDetails?.({
59
- processName: details?.processName,
60
- pid: details?.pid,
61
- occurredAt: Date.now(),
62
- });
63
- const merged = mergeCrashDetails(phase, details, enriched, fallbackSummary);
64
- crashLogger.debug('crash details: %o', {
65
- phase: merged.phase,
66
- source: merged.source,
67
- summary: merged.summary,
68
- signal: merged.signal,
69
- exceptionType: merged.exceptionType,
70
- processName: merged.processName,
71
- pid: merged.pid,
72
- });
73
- notifyCrash(new NativeCrashError(currentTestFilePath, merged));
77
+ const session = appSession;
78
+ const state = await session?.getState();
79
+ const logs = session?.getLogs() ?? [];
80
+ const rawLines = getMatchingCrashLines(logs, pending.occurredAt);
81
+ if (state?.status === 'running' && trigger === 'bridge-disconnect') {
82
+ crashLogger.debug('runtime bridge disconnected without confirmed app death');
83
+ notifyFailure(new RuntimeDisconnectError(pending.testFilePath, buildRuntimeDisconnectDetails(pending.phase, rawLines)));
84
+ return;
85
+ }
86
+ alive = false;
87
+ crashLogger.debug('native crash detected (phase=%s)', pending.phase);
88
+ for (const line of rawLines) {
89
+ crashLogger.debug('%s', line);
90
+ }
91
+ const fallbackSummary = trigger === 'bridge-disconnect'
92
+ ? 'The app process exited after the runtime bridge disconnected, but no crash log lines were found.'
93
+ : 'The app process exited, but no crash log lines were found.';
94
+ const details = buildNativeCrashDetails(pending.phase, rawLines, fallbackSummary);
95
+ const extractedDetails = session?.getCrashDetails
96
+ ? await session
97
+ .getCrashDetails({
98
+ occurredAt: pending.occurredAt,
99
+ pid: getStatePid(state),
100
+ processName: details.processName,
101
+ testFilePath: pending.testFilePath,
102
+ })
103
+ .catch((error) => {
104
+ crashLogger.warn('failed to extract native crash details: %s', error);
105
+ return null;
106
+ })
107
+ : null;
108
+ notifyFailure(new NativeCrashError(pending.testFilePath, mergeCrashDetails(details, extractedDetails)));
74
109
  }
75
110
  finally {
76
111
  isResolvingCrash = false;
112
+ pendingTimer = null;
77
113
  }
78
114
  };
79
- const confirmAndHandleCrash = async (phase, details, fallbackSummary) => {
80
- if (disposed || !monitoring)
115
+ const startCrashResolution = (trigger) => {
116
+ if (disposed || !monitoring || isResolvingCrash) {
81
117
  return;
82
- try {
83
- const isRunning = await platformRunner.isAppRunning();
84
- if (!isRunning) {
85
- void handleCrash(phase, details, fallbackSummary);
86
- }
87
- }
88
- catch (error) {
89
- crashLogger.debug('crash confirmation failed', error);
90
118
  }
119
+ clearPendingTimer();
120
+ const pending = {
121
+ testFilePath: currentTestFilePath,
122
+ phase: currentPhase,
123
+ occurredAt: Date.now(),
124
+ };
125
+ pendingTimer = setTimeout(() => {
126
+ void classify(pending, trigger);
127
+ }, trigger === 'bridge-disconnect' ? CRASH_CLASSIFICATION_SETTLE_MS : 0);
91
128
  };
92
- const extractCrashDetails = (event) => event.crashDetails
93
- ? {
94
- source: event.crashDetails.source ?? event.source,
95
- summary: event.crashDetails.summary,
96
- signal: event.crashDetails.signal,
97
- exceptionType: event.crashDetails.exceptionType,
98
- processName: event.crashDetails.processName,
99
- pid: event.crashDetails.pid ?? event.pid,
100
- stackTrace: event.crashDetails.stackTrace,
101
- rawLines: event.crashDetails.rawLines ??
102
- (event.line ? [event.line] : undefined),
103
- }
104
- : undefined;
105
- const appMonitorListener = (event) => {
106
- if (disposed || !monitoring)
107
- return;
108
- if (event.type === 'app_started') {
109
- alive = true;
110
- return;
111
- }
129
+ const appSessionListener = (event) => {
112
130
  if (event.type === 'app_exited') {
113
- const details = extractCrashDetails(event);
114
- if (event.isConfirmed ?? event.source === 'polling') {
115
- void handleCrash(currentPhase, details);
116
- }
117
- else {
118
- void confirmAndHandleCrash(currentPhase, details);
119
- }
120
- return;
121
- }
122
- if (event.type === 'possible_crash') {
123
- const details = extractCrashDetails(event);
124
- const fallback = `possible crash signal (${event.source ?? 'unknown'})`;
125
- if (event.isConfirmed) {
126
- void handleCrash(currentPhase, details, fallback);
127
- }
128
- else {
129
- void confirmAndHandleCrash(currentPhase, details, fallback);
130
- }
131
+ startCrashResolution('app-exit');
131
132
  }
132
133
  };
133
- appMonitor.addListener(appMonitorListener);
134
+ const setAppSession = (session) => {
135
+ appSession?.removeListener(appSessionListener);
136
+ appSession = session;
137
+ alive = Boolean(session);
138
+ session?.addListener(appSessionListener);
139
+ };
140
+ setAppSession(initialAppSession);
134
141
  const watch = (testFilePath, phase) => {
135
142
  currentTestFilePath = testFilePath;
136
143
  currentPhase = phase;
@@ -152,25 +159,32 @@ export const createCrashMonitor = ({ appMonitor, platformRunner, }) => {
152
159
  isAlive: () => alive,
153
160
  stop: async () => {
154
161
  monitoring = false;
155
- await appMonitor.stop();
162
+ clearPendingTimer();
163
+ await sleep(0);
156
164
  },
157
165
  start: async () => {
158
166
  monitoring = true;
159
- await appMonitor.start();
160
167
  },
161
168
  reset: () => {
162
- alive = false;
169
+ alive = Boolean(appSession);
163
170
  watchers.clear();
164
171
  isResolvingCrash = false;
165
172
  currentTestFilePath = '';
173
+ clearPendingTimer();
174
+ },
175
+ setAppSession,
176
+ handleBridgeDisconnect: () => {
177
+ startCrashResolution('bridge-disconnect');
166
178
  },
167
179
  dispose: async () => {
168
180
  disposed = true;
169
181
  monitoring = false;
170
182
  watchers.clear();
171
183
  isResolvingCrash = false;
172
- appMonitor.removeListener(appMonitorListener);
173
- await appMonitor.dispose();
184
+ clearPendingTimer();
185
+ appSession?.removeListener(appSessionListener);
186
+ appSession = null;
187
+ alive = false;
174
188
  },
175
189
  };
176
190
  };
package/dist/errors.d.ts CHANGED
@@ -24,6 +24,9 @@ export type NativeCrashPhase = 'startup' | 'execution';
24
24
  export type NativeCrashDetails = AppCrashDetails & {
25
25
  phase: NativeCrashPhase;
26
26
  };
27
+ export type RuntimeDisconnectDetails = AppCrashDetails & {
28
+ phase: NativeCrashPhase;
29
+ };
27
30
  export declare class NativeCrashError extends HarnessError {
28
31
  readonly testFilePath: string;
29
32
  readonly details: NativeCrashDetails;
@@ -31,4 +34,11 @@ export declare class NativeCrashError extends HarnessError {
31
34
  constructor(testFilePath: string, details: NativeCrashDetails, lastKnownTest?: string | undefined);
32
35
  get phase(): NativeCrashPhase;
33
36
  }
37
+ export declare class RuntimeDisconnectError extends HarnessError {
38
+ readonly testFilePath: string;
39
+ readonly details: RuntimeDisconnectDetails;
40
+ constructor(testFilePath: string, details: RuntimeDisconnectDetails);
41
+ get phase(): NativeCrashPhase;
42
+ }
43
+ export type HarnessRuntimeFailure = NativeCrashError | RuntimeDisconnectError;
34
44
  //# sourceMappingURL=errors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AACvE,OAAO,EACL,iBAAiB,EACjB,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,GACzB,MAAM,qCAAqC,CAAC;AAE7C,qBAAa,sBAAuB,SAAQ,YAAY;;CAKvD;AAED,qBAAa,mBAAoB,SAAQ,YAAY;aACvB,UAAU,EAAE,MAAM;gBAAlB,UAAU,EAAE,MAAM;CAI/C;AAED,qBAAa,0BAA2B,SAAQ,YAAY;;CAK3D;AAED,qBAAa,yBAA0B,SAAQ,YAAY;aAC7B,OAAO,EAAE,MAAM;gBAAf,OAAO,EAAE,MAAM;CAM5C;AAED,qBAAa,4BAA6B,SAAQ,YAAY;aAE1C,WAAW,EAAE,MAAM;aACnB,QAAQ,EAAE,MAAM;gBADhB,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM;CAQnC;AAED,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,WAAW,CAAC;AAEvD,MAAM,MAAM,kBAAkB,GAAG,eAAe,GAAG;IACjD,KAAK,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAmDF,qBAAa,gBAAiB,SAAQ,YAAY;aAE9B,YAAY,EAAE,MAAM;aACpB,OAAO,EAAE,kBAAkB;aAC3B,aAAa,CAAC,EAAE,MAAM;gBAFtB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,kBAAkB,EAC3B,aAAa,CAAC,EAAE,MAAM,YAAA;IAOxC,IAAI,KAAK,qBAER;CACF"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,YAAY,EAAE,MAAM,6BAA6B,CAAC;AACvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AACvE,OAAO,EACL,iBAAiB,EACjB,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,GACzB,MAAM,qCAAqC,CAAC;AAE7C,qBAAa,sBAAuB,SAAQ,YAAY;;CAKvD;AAED,qBAAa,mBAAoB,SAAQ,YAAY;aACvB,UAAU,EAAE,MAAM;gBAAlB,UAAU,EAAE,MAAM;CAI/C;AAED,qBAAa,0BAA2B,SAAQ,YAAY;;CAK3D;AAED,qBAAa,yBAA0B,SAAQ,YAAY;aAC7B,OAAO,EAAE,MAAM;gBAAf,OAAO,EAAE,MAAM;CAM5C;AAED,qBAAa,4BAA6B,SAAQ,YAAY;aAE1C,WAAW,EAAE,MAAM;aACnB,QAAQ,EAAE,MAAM;gBADhB,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM;CAQnC;AAED,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,WAAW,CAAC;AAEvD,MAAM,MAAM,kBAAkB,GAAG,eAAe,GAAG;IACjD,KAAK,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,eAAe,GAAG;IACvD,KAAK,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAmEF,qBAAa,gBAAiB,SAAQ,YAAY;aAE9B,YAAY,EAAE,MAAM;aACpB,OAAO,EAAE,kBAAkB;aAC3B,aAAa,CAAC,EAAE,MAAM;gBAFtB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,kBAAkB,EAC3B,aAAa,CAAC,EAAE,MAAM,YAAA;IAOxC,IAAI,KAAK,qBAER;CACF;AA0BD,qBAAa,sBAAuB,SAAQ,YAAY;aAEpC,YAAY,EAAE,MAAM;aACpB,OAAO,EAAE,wBAAwB;gBADjC,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,wBAAwB;IAOnD,IAAI,KAAK,qBAER;CACF;AAED,MAAM,MAAM,qBAAqB,GAAG,gBAAgB,GAAG,sBAAsB,CAAC"}
package/dist/errors.js CHANGED
@@ -1,4 +1,4 @@
1
- import { HarnessError } from '@react-native-harness/tools';
1
+ import { formatPath, HarnessError } from '@react-native-harness/tools';
2
2
  export { StartupStallError, } from '@react-native-harness/bundler-metro';
3
3
  export class NoRunnerSpecifiedError extends HarnessError {
4
4
  constructor() {
@@ -39,12 +39,21 @@ export class MetroPortRangeExhaustedError extends HarnessError {
39
39
  this.name = 'MetroPortRangeExhaustedError';
40
40
  }
41
41
  }
42
- const buildNativeCrashMessage = ({ phase, summary, signal, exceptionType, processName, pid, stackTrace, artifactType, }) => {
42
+ const buildNativeCrashMessage = ({ phase, summary, signal, exceptionType, processName, pid, stackTrace, artifactType, artifactPath, enrichmentArtifacts, }) => {
43
43
  const lines = [
44
44
  phase === 'startup'
45
45
  ? 'The native app crashed while preparing to run this test file.'
46
46
  : 'The native app crashed during test execution.',
47
47
  ];
48
+ lines.push(artifactPath
49
+ ? `Harness extracted the crash log: ${formatPath(artifactPath)}`
50
+ : "Harness couldn't extract the crash log.");
51
+ if (enrichmentArtifacts && enrichmentArtifacts.length > 0) {
52
+ lines.push('Additional crash artifacts:');
53
+ for (const artifact of enrichmentArtifacts) {
54
+ lines.push(` - ${formatPath(artifact.artifactPath)}`);
55
+ }
56
+ }
48
57
  const hasCrashBlock = summary?.includes('\n') ?? false;
49
58
  const shouldRenderSummary = Boolean(summary) &&
50
59
  !(!hasCrashBlock && artifactType === 'ios-crash-report');
@@ -89,3 +98,33 @@ export class NativeCrashError extends HarnessError {
89
98
  return this.details.phase;
90
99
  }
91
100
  }
101
+ const buildRuntimeDisconnectMessage = ({ phase, summary, rawLines, }) => {
102
+ const lines = [
103
+ phase === 'startup'
104
+ ? 'The native runtime disconnected while preparing to run this test file.'
105
+ : 'The native runtime disconnected during test execution.',
106
+ ];
107
+ if (summary) {
108
+ lines.push('');
109
+ lines.push(summary);
110
+ }
111
+ if (rawLines && rawLines.length > 0 && summary !== rawLines.join('\n')) {
112
+ lines.push('');
113
+ lines.push(...rawLines);
114
+ }
115
+ return lines.join('\n');
116
+ };
117
+ export class RuntimeDisconnectError extends HarnessError {
118
+ testFilePath;
119
+ details;
120
+ constructor(testFilePath, details) {
121
+ super(buildRuntimeDisconnectMessage(details));
122
+ this.testFilePath = testFilePath;
123
+ this.details = details;
124
+ this.name = 'RuntimeDisconnectError';
125
+ this.stack = `${this.name}: ${this.message.split('\n')[0]}`;
126
+ }
127
+ get phase() {
128
+ return this.details.phase;
129
+ }
130
+ }
@@ -1,4 +1,6 @@
1
- import type { OnTestFailure, OnTestStart, OnTestSuccess, Test, TestWatcher, Config } from 'jest-runner';
1
+ import type { Test, TestWatcher, Config, TestEvents } from 'jest-runner';
2
2
  import { type HarnessSession } from './harness-session.js';
3
- export declare const executeRun: (session: HarnessSession, tests: Array<Test>, watcher: TestWatcher, onStart: OnTestStart, onResult: OnTestSuccess, onFailure: OnTestFailure, globalConfig: Config.GlobalConfig) => Promise<void>;
3
+ type EmitTestEvent = <Name extends keyof TestEvents>(eventName: Name, ...eventData: TestEvents[Name]) => Promise<void>;
4
+ export declare const executeRun: (session: HarnessSession, tests: Array<Test>, watcher: TestWatcher, emitEvent: EmitTestEvent, globalConfig: Config.GlobalConfig) => Promise<void>;
5
+ export {};
4
6
  //# sourceMappingURL=execute-run.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"execute-run.d.ts","sourceRoot":"","sources":["../src/execute-run.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EACb,WAAW,EACX,aAAa,EACb,IAAI,EACJ,WAAW,EACX,MAAM,EACP,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,KAAK,cAAc,EAAwB,MAAM,sBAAsB,CAAC;AAsCjF,eAAO,MAAM,UAAU,GACrB,SAAS,cAAc,EACvB,OAAO,KAAK,CAAC,IAAI,CAAC,EAClB,SAAS,WAAW,EACpB,SAAS,WAAW,EACpB,UAAU,aAAa,EACvB,WAAW,aAAa,EACxB,cAAc,MAAM,CAAC,YAAY,KAChC,OAAO,CAAC,IAAI,CA8Id,CAAC"}
1
+ {"version":3,"file":"execute-run.d.ts","sourceRoot":"","sources":["../src/execute-run.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,EACJ,WAAW,EACX,MAAM,EACN,UAAU,EACX,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,KAAK,cAAc,EAAwB,MAAM,sBAAsB,CAAC;AAsBjF,KAAK,aAAa,GAAG,CAAC,IAAI,SAAS,MAAM,UAAU,EACjD,SAAS,EAAE,IAAI,EACf,GAAG,SAAS,EAAE,UAAU,CAAC,IAAI,CAAC,KAC3B,OAAO,CAAC,IAAI,CAAC,CAAC;AAmFnB,eAAO,MAAM,UAAU,GACrB,SAAS,cAAc,EACvB,OAAO,KAAK,CAAC,IAAI,CAAC,EAClB,SAAS,WAAW,EACpB,WAAW,aAAa,EACxB,cAAc,MAAM,CAAC,YAAY,KAChC,OAAO,CAAC,IAAI,CA8Ld,CAAC"}