Skip to content

Commit 40f0d2f

Browse files
antonisclaude
andcommitted
test(e2e): Add auto init from JS tests for Android
Implements Android E2E testing infrastructure to verify both manual native initialization and auto initialization from JavaScript, matching the iOS implementation and resolving issue #4912. Key additions: - Jest configs for android.auto and android.manual test modes - Build scripts that toggle SENTRY_DISABLE_NATIVE_START at compile time - Test scripts to run auto and manual test suites separately - App start crash testing via flag file mechanism - TestControlModule to enable/disable crash-on-start from JS - Comprehensive E2E test documentation Unlike iOS which uses launch arguments at runtime, Android requires separate builds with different build configurations to control native initialization. Closes #4912 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent e6505b7 commit 40f0d2f

File tree

14 files changed

+541
-2
lines changed

14 files changed

+541
-2
lines changed

samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,21 @@ class MainApplication :
4141
RNSentrySDK.init(this)
4242
}
4343

44+
// Check for crash-on-start intent for testing
45+
if (shouldCrashOnStart()) {
46+
throw RuntimeException("This was intentional test crash before JS started.")
47+
}
48+
4449
SoLoader.init(this, OpenSourceMergedSoMapping)
4550
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
4651
// If you opted-in for the New Architecture, we load the native entry point for this app.
4752
load()
4853
}
4954
}
55+
56+
private fun shouldCrashOnStart(): Boolean {
57+
// Check if crash flag file exists (for E2E testing)
58+
val crashFile = getFileStreamPath(".sentry_crash_on_start")
59+
return crashFile.exists()
60+
}
5061
}

samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/SamplePackage.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,38 @@ private void crashNow() {
7777
}
7878
});
7979

80+
modules.add(
81+
new ReactContextBaseJavaModule(reactContext) {
82+
@Override
83+
public String getName() {
84+
return "TestControlModule";
85+
}
86+
87+
@ReactMethod
88+
public void enableCrashOnStart(Promise promise) {
89+
try {
90+
// Create flag file to trigger crash on next app start
91+
getReactApplicationContext()
92+
.openFileOutput(".sentry_crash_on_start", ReactApplicationContext.MODE_PRIVATE)
93+
.close();
94+
promise.resolve(true);
95+
} catch (Exception e) {
96+
promise.reject("ERROR", "Failed to enable crash on start", e);
97+
}
98+
}
99+
100+
@ReactMethod
101+
public void disableCrashOnStart(Promise promise) {
102+
try {
103+
// Delete flag file
104+
getReactApplicationContext().deleteFile(".sentry_crash_on_start");
105+
promise.resolve(true);
106+
} catch (Exception e) {
107+
promise.reject("ERROR", "Failed to disable crash on start", e);
108+
}
109+
}
110+
});
111+
80112
return modules;
81113
}
82114
}

samples/react-native/e2e/README.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# E2E Testing Guide
2+
3+
This directory contains end-to-end tests for the React Native sample app, testing both **manual native init** and **auto init from JS** modes.
4+
5+
## Test Modes
6+
7+
### Manual Native Init Mode
8+
Native SDK is initialized **before** JavaScript loads, allowing capture of app start crashes.
9+
- **iOS**: Native init via `RNSentrySDK.start()` in AppDelegate
10+
- **Android**: Native init via `RNSentrySDK.init(this)` in MainApplication
11+
12+
### Auto Init from JS Mode
13+
SDK is initialized **from JavaScript** after app loads (traditional behavior).
14+
- **iOS**: Native init is skipped via launch argument `sentryDisableNativeStart`
15+
- **Android**: Native init is disabled at build time via `SENTRY_DISABLE_NATIVE_START=true`
16+
17+
## Running Tests
18+
19+
### Android
20+
21+
#### Manual Native Init Tests
22+
```bash
23+
# Build with native init enabled
24+
yarn build-android-debug-manual
25+
26+
# Run manual mode tests
27+
yarn test-android-manual
28+
```
29+
30+
#### Auto Init from JS Tests
31+
```bash
32+
# Build with native init disabled
33+
yarn build-android-debug-auto
34+
35+
# Run auto mode tests
36+
yarn test-android-auto
37+
```
38+
39+
### iOS
40+
41+
#### Manual Native Init Tests
42+
```bash
43+
# Build
44+
yarn build-ios-debug
45+
46+
# Run manual mode tests
47+
yarn test-ios-manual
48+
```
49+
50+
#### Auto Init from JS Tests
51+
```bash
52+
# Build (same build works for both modes)
53+
yarn build-ios-debug
54+
55+
# Run auto mode tests (uses launch argument to disable native init)
56+
yarn test-ios-auto
57+
```
58+
59+
## Test Structure
60+
61+
```
62+
e2e/
63+
├── jest.config.{platform}.{mode}.js # Test configurations
64+
├── setup.{platform}.{mode}.ts # Test setup files
65+
└── tests/
66+
├── captureMessage/ # Basic message capture tests
67+
│ ├── *.test.{platform}.{mode}.ts
68+
│ └── *.test.yml # Maestro flows
69+
├── captureAppStartCrash/ # App start crash tests (manual mode only)
70+
│ ├── *.test.{platform}.manual.ts
71+
│ └── *.test.{platform}.manual.yml
72+
└── ...
73+
```
74+
75+
## Platform Differences
76+
77+
### iOS
78+
- Uses **launch arguments** to control native init at runtime
79+
- Same build can test both modes
80+
- Launch argument: `sentryDisableNativeStart: true/false`
81+
82+
### Android
83+
- Uses **build configuration** to control native init at compile time
84+
- Requires separate builds for each mode
85+
- Build config: `SENTRY_DISABLE_NATIVE_START=true/false`
86+
- Environment variable set by build scripts
87+
88+
## Adding New Tests
89+
90+
### Dual-Mode Tests (runs in both auto and manual)
91+
1. Create test file: `myTest.test.{platform}.{mode}.ts`
92+
2. Create Maestro flow: `myTest.test.yml` (Android) or `myTest.test.{platform}.{mode}.yml` (iOS)
93+
3. Test should work regardless of init mode
94+
95+
### Manual-Only Tests (app start crashes)
96+
1. Create test file: `myTest.test.{platform}.manual.ts`
97+
2. These tests verify native-only features before JS loads
98+
3. Cannot test in auto mode (JS not loaded yet)
99+
100+
## App Start Crash Testing
101+
102+
### Android
103+
Uses a flag file mechanism:
104+
1. Call `TestControlModule.enableCrashOnStart()` from JS
105+
2. Restart app → native crash before JS loads
106+
3. Restart again → crash event is sent
107+
4. Call `TestControlModule.disableCrashOnStart()` to clean up
108+
109+
### iOS
110+
Uses launch arguments:
111+
1. Launch with `sentryCrashOnStart: true`
112+
2. App crashes in `application:didFinishLaunchingWithOptions`
113+
3. Restart → crash event is sent
114+
115+
## Debugging
116+
117+
### View test output
118+
```bash
119+
# Android
120+
adb logcat | grep -i sentry
121+
122+
# iOS
123+
xcrun simctl spawn booted log stream --predicate 'processImagePath contains "sentryreactnativesample"'
124+
```
125+
126+
### Manual testing
127+
```bash
128+
# Android - Install specific build
129+
adb install -r app-manual.apk # or app-auto.apk
130+
adb shell am start -n io.sentry.reactnative.sample/.MainActivity
131+
132+
# iOS - Use Xcode or simulator
133+
open -a Simulator
134+
xcrun simctl install booted sentryreactnativesample.app
135+
xcrun simctl launch booted io.sentry.reactnative.sample
136+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const path = require('path');
2+
const baseConfig = require('./jest.config.base');
3+
4+
/** @type {import('@jest/types').Config.InitialOptions} */
5+
module.exports = {
6+
...baseConfig,
7+
globalSetup: path.resolve(__dirname, 'setup.android.auto.ts'),
8+
testMatch: [
9+
...baseConfig.testMatch,
10+
'<rootDir>/e2e/**/*.test.android.ts',
11+
'<rootDir>/e2e/**/*.test.android.auto.ts',
12+
],
13+
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { setAutoInitTest } from './utils/environment';
2+
3+
function setupAuto() {
4+
setAutoInitTest();
5+
}
6+
7+
export default setupAuto;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, beforeAll, expect, afterAll } from '@jest/globals';
2+
import { Envelope, EventItem } from '@sentry/core';
3+
4+
import {
5+
createSentryServer,
6+
containingEvent,
7+
} from '../../utils/mockedSentryServer';
8+
import { getItemOfTypeFrom } from '../../utils/event';
9+
import { maestro } from '../../utils/maestro';
10+
11+
describe('Capture app start crash (Android)', () => {
12+
let sentryServer = createSentryServer();
13+
14+
let envelope: Envelope;
15+
16+
beforeAll(async () => {
17+
await sentryServer.start();
18+
19+
const envelopePromise = sentryServer.waitForEnvelope(containingEvent);
20+
21+
await maestro('tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.yml');
22+
23+
envelope = await envelopePromise;
24+
}, 300000); // 5 minutes timeout for crash handling
25+
26+
afterAll(async () => {
27+
await sentryServer.close();
28+
});
29+
30+
it('envelope contains sdk metadata', async () => {
31+
const item = getItemOfTypeFrom<EventItem>(envelope, 'event');
32+
33+
expect(item).toEqual([
34+
{
35+
content_type: 'application/json',
36+
length: expect.any(Number),
37+
type: 'event',
38+
},
39+
expect.objectContaining({
40+
platform: 'java',
41+
sdk: expect.objectContaining({
42+
name: 'sentry.java.android.react-native',
43+
packages: expect.arrayContaining([
44+
expect.objectContaining({
45+
name: 'maven:io.sentry:sentry-android-core',
46+
}),
47+
expect.objectContaining({
48+
name: 'npm:@sentry/react-native',
49+
}),
50+
]),
51+
}),
52+
}),
53+
]);
54+
});
55+
56+
it('captures app start crash exception', async () => {
57+
const item = getItemOfTypeFrom<EventItem>(envelope, 'event');
58+
59+
expect(item?.[1]).toEqual(
60+
expect.objectContaining({
61+
exception: expect.objectContaining({
62+
values: expect.arrayContaining([
63+
expect.objectContaining({
64+
type: 'RuntimeException',
65+
value: 'This was intentional test crash before JS started.',
66+
mechanism: expect.objectContaining({
67+
handled: false,
68+
type: 'UncaughtExceptionHandler',
69+
}),
70+
}),
71+
]),
72+
}),
73+
}),
74+
);
75+
});
76+
77+
it('crash happened before JS was loaded', async () => {
78+
const item = getItemOfTypeFrom<EventItem>(envelope, 'event');
79+
80+
// Verify this is a native crash, not from JavaScript
81+
expect(item?.[1]).toEqual(
82+
expect.objectContaining({
83+
platform: 'java',
84+
}),
85+
);
86+
87+
// Should not have JavaScript context since JS wasn't loaded yet
88+
expect(item?.[1]?.contexts?.react_native_context).toBeUndefined();
89+
});
90+
91+
it('contains device and app context', async () => {
92+
const item = getItemOfTypeFrom<EventItem>(envelope, 'event');
93+
94+
expect(item?.[1]).toEqual(
95+
expect.objectContaining({
96+
contexts: expect.objectContaining({
97+
device: expect.objectContaining({
98+
brand: expect.any(String),
99+
manufacturer: expect.any(String),
100+
model: expect.any(String),
101+
}),
102+
app: expect.objectContaining({
103+
app_identifier: 'io.sentry.reactnative.sample',
104+
app_name: expect.any(String),
105+
app_version: expect.any(String),
106+
}),
107+
os: expect.objectContaining({
108+
name: 'Android',
109+
version: expect.any(String),
110+
}),
111+
}),
112+
}),
113+
);
114+
});
115+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
appId: io.sentry.reactnative.sample
2+
---
3+
# First launch: Enable crash flag and exit gracefully
4+
- launchApp:
5+
clearState: true
6+
stopApp: true
7+
8+
- tapOn: "Errors"
9+
- tapOn: "Enable Crash on Start"
10+
- stopApp
11+
12+
# Second launch: App will crash on start due to flag file
13+
- launchApp:
14+
clearState: false
15+
stopApp: false
16+
17+
# Third launch: Crash event is sent before crashing again
18+
- launchApp:
19+
clearState: false
20+
stopApp: false
21+
22+
# Fourth launch: Disable the crash flag so app can run normally
23+
- launchApp:
24+
clearState: false
25+
stopApp: false
26+
27+
- tapOn: "Errors"
28+
- tapOn: "Disable Crash on Start"

0 commit comments

Comments
 (0)