@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.
- package/dist/__tests__/bridge.test.js +23 -2
- package/dist/__tests__/crash-monitor.test.js +146 -192
- package/dist/__tests__/errors.test.js +39 -0
- package/dist/__tests__/execute-run.test.js +190 -26
- package/dist/__tests__/harness-session.test.js +63 -239
- package/dist/__tests__/index.test.js +59 -10
- package/dist/__tests__/test-file-platform-filter.test.d.ts +2 -0
- package/dist/__tests__/test-file-platform-filter.test.d.ts.map +1 -0
- package/dist/__tests__/test-file-platform-filter.test.js +47 -0
- package/dist/crash-monitor.d.ts +5 -4
- package/dist/crash-monitor.d.ts.map +1 -1
- package/dist/crash-monitor.js +116 -102
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +41 -2
- package/dist/execute-run.d.ts +4 -2
- package/dist/execute-run.d.ts.map +1 -1
- package/dist/execute-run.js +85 -7
- package/dist/harness-session.d.ts +21 -1
- package/dist/harness-session.d.ts.map +1 -1
- package/dist/harness-session.js +134 -39
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -2
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +1 -0
- package/dist/test-file-platform-filter.d.ts +5 -0
- package/dist/test-file-platform-filter.d.ts.map +1 -0
- package/dist/test-file-platform-filter.js +40 -0
- package/dist/toTestResult.d.ts +1 -0
- package/dist/toTestResult.d.ts.map +1 -1
- package/dist/toTestResult.js +2 -2
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/src/__tests__/bridge.test.ts +37 -2
- package/src/__tests__/crash-monitor.test.ts +182 -231
- package/src/__tests__/errors.test.ts +75 -0
- package/src/__tests__/execute-run.test.ts +235 -34
- package/src/__tests__/harness-session.test.ts +93 -0
- package/src/__tests__/index.test.ts +89 -11
- package/src/__tests__/test-file-platform-filter.test.ts +83 -0
- package/src/crash-monitor.ts +192 -131
- package/src/errors.ts +62 -1
- package/src/execute-run.ts +125 -11
- package/src/harness-session.ts +177 -42
- package/src/index.ts +40 -11
- package/src/run.ts +1 -0
- package/src/test-file-platform-filter.ts +57 -0
- 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
|
-
|
|
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(),
|
|
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(),
|
|
83
|
-
await runner.runTests([makeTest()], makeWatcher(),
|
|
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(),
|
|
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(),
|
|
101
|
-
await runner.runTests([makeTest()], makeWatcher(),
|
|
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(),
|
|
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(),
|
|
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(),
|
|
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
|
+
});
|
package/src/crash-monitor.ts
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type AppMonitor,
|
|
3
2
|
type AppCrashDetails,
|
|
4
|
-
type
|
|
5
|
-
type
|
|
6
|
-
type
|
|
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
|
-
|
|
40
|
-
platformRunner: HarnessPlatformRunner;
|
|
47
|
+
appSession?: AppSession | null;
|
|
41
48
|
};
|
|
42
49
|
|
|
43
|
-
type
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
fallbackSummary?: string,
|
|
76
|
+
rawLines: string[],
|
|
77
|
+
summary: string
|
|
56
78
|
): NativeCrashDetails => ({
|
|
57
79
|
phase,
|
|
58
|
-
source:
|
|
59
|
-
summary:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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:
|
|
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
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
fallbackSummary?: string,
|
|
149
|
+
const classify = async (
|
|
150
|
+
pending: PendingCrash,
|
|
151
|
+
trigger: 'bridge-disconnect' | 'app-exit'
|
|
141
152
|
) => {
|
|
142
|
-
if (disposed || !monitoring)
|
|
153
|
+
if (disposed || !monitoring || isResolvingCrash) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
isResolvingCrash = true;
|
|
158
|
+
|
|
143
159
|
try {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
245
|
-
|
|
303
|
+
clearPendingTimer();
|
|
304
|
+
appSession?.removeListener(appSessionListener);
|
|
305
|
+
appSession = null;
|
|
306
|
+
alive = false;
|
|
246
307
|
},
|
|
247
308
|
};
|
|
248
309
|
};
|