Skip to content

Commit 1ad8373

Browse files
antonisclaude
andauthored
test(e2e): Add auto init from JS tests for Android (#5583)
* 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> * fix(e2e): Add scrolling to find crash control buttons in Android test The crash control buttons are off-screen, so the Maestro flow needs to scroll to find them before tapping. This matches the pattern used in other Android E2E tests. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(e2e): Make Android crash flag auto-expire after one crash The crash flag file was persisting across app launches, causing the app to crash indefinitely. Now the flag auto-deletes when read, allowing: 1. First launch: Enable flag 2. Second launch: Read flag, delete it, then crash 3. Third launch: Start normally and send crash report This solves the chicken-and-egg problem where the app couldn't reach JavaScript to clear the flag because it kept crashing before JS loaded. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(e2e): Handle wrapped exceptions in Android crash test Android wraps exceptions thrown in Application.onCreate() with: "Unable to create application... RuntimeException: <original message>" Updated the test to check if ANY exception in the chain contains our intentional crash message, rather than expecting an exact match on the first exception. Test now passes locally and should pass in CI. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Clean up notes for now --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent ed126ab commit 1ad8373

File tree

13 files changed

+416
-2
lines changed

13 files changed

+416
-2
lines changed

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,27 @@ 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+
if (crashFile.exists()) {
60+
// Delete the flag immediately so we only crash once
61+
// This allows the next launch to succeed and send the crash report
62+
crashFile.delete()
63+
return true
64+
}
65+
return false
66+
}
5067
}

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
}
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: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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+
// Android wraps onCreate exceptions, so check that at least one exception
60+
// contains our intentional crash message
61+
const exceptions = item?.[1]?.exception?.values;
62+
expect(exceptions).toBeDefined();
63+
64+
const hasIntentionalCrash = exceptions?.some(
65+
(ex: any) =>
66+
ex.type === 'RuntimeException' &&
67+
ex.value?.includes('This was intentional test crash before JS started.')
68+
);
69+
70+
expect(hasIntentionalCrash).toBe(true);
71+
72+
// Verify at least one exception has UncaughtExceptionHandler mechanism
73+
const hasUncaughtHandler = exceptions?.some(
74+
(ex: any) => ex.mechanism?.type === 'UncaughtExceptionHandler'
75+
);
76+
77+
expect(hasUncaughtHandler).toBe(true);
78+
});
79+
80+
it('crash happened before JS was loaded', async () => {
81+
const item = getItemOfTypeFrom<EventItem>(envelope, 'event');
82+
83+
// Verify this is a native crash, not from JavaScript
84+
expect(item?.[1]).toEqual(
85+
expect.objectContaining({
86+
platform: 'java',
87+
}),
88+
);
89+
90+
// Should not have JavaScript context since JS wasn't loaded yet
91+
expect(item?.[1]?.contexts?.react_native_context).toBeUndefined();
92+
});
93+
94+
it('contains device and app context', async () => {
95+
const item = getItemOfTypeFrom<EventItem>(envelope, 'event');
96+
97+
expect(item?.[1]).toEqual(
98+
expect.objectContaining({
99+
contexts: expect.objectContaining({
100+
device: expect.objectContaining({
101+
brand: expect.any(String),
102+
manufacturer: expect.any(String),
103+
model: expect.any(String),
104+
}),
105+
app: expect.objectContaining({
106+
app_identifier: 'io.sentry.reactnative.sample',
107+
app_name: expect.any(String),
108+
app_version: expect.any(String),
109+
}),
110+
os: expect.objectContaining({
111+
name: 'Android',
112+
version: expect.any(String),
113+
}),
114+
}),
115+
}),
116+
);
117+
});
118+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
# App launches on ErrorsTab by default, wait for screen to load
9+
- waitForAnimationToEnd:
10+
timeout: 2000
11+
12+
# Scroll down to find the "Enable Crash on Start" button (Android only)
13+
- scrollUntilVisible:
14+
element: "Enable Crash on Start"
15+
timeout: 10000
16+
direction: DOWN
17+
18+
- tapOn: "Enable Crash on Start"
19+
- stopApp
20+
21+
# Second launch: App crashes on start
22+
# The crash flag auto-deletes when read, so app only crashes once
23+
- launchApp:
24+
clearState: false
25+
stopApp: false
26+
27+
# Third launch: App starts normally and sends the crash event
28+
- launchApp:
29+
clearState: false
30+
stopApp: false
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, it, beforeAll, expect, afterAll } from '@jest/globals';
2+
import { Envelope, EventItem } from '@sentry/core';
3+
4+
import {
5+
createSentryServer,
6+
containingEventWithAndroidMessage,
7+
} from '../../utils/mockedSentryServer';
8+
import { getItemOfTypeFrom } from '../../utils/event';
9+
import { maestro } from '../../utils/maestro';
10+
11+
describe('Capture message (auto init from JS)', () => {
12+
let sentryServer = createSentryServer();
13+
14+
let envelope: Envelope;
15+
16+
beforeAll(async () => {
17+
await sentryServer.start();
18+
19+
const envelopePromise = sentryServer.waitForEnvelope(
20+
containingEventWithAndroidMessage('Captured message'),
21+
);
22+
23+
await maestro('tests/captureMessage/captureMessage.test.yml');
24+
25+
envelope = await envelopePromise;
26+
}, 240000); // 240 seconds timeout
27+
28+
afterAll(async () => {
29+
await sentryServer.close();
30+
});
31+
32+
it('envelope contains message event', async () => {
33+
const item = getItemOfTypeFrom<EventItem>(envelope, 'event');
34+
35+
expect(item).toEqual([
36+
{
37+
content_type: 'application/json',
38+
length: expect.any(Number),
39+
type: 'event',
40+
},
41+
expect.objectContaining({
42+
level: 'info',
43+
message: {
44+
message: 'Captured message',
45+
},
46+
platform: 'javascript',
47+
}),
48+
]);
49+
});
50+
51+
it('contains device context', async () => {
52+
const item = getItemOfTypeFrom<EventItem>(envelope, 'event');
53+
54+
expect(item?.[1]).toEqual(
55+
expect.objectContaining({
56+
contexts: expect.objectContaining({
57+
device: expect.objectContaining({
58+
battery_level: expect.any(Number),
59+
brand: expect.any(String),
60+
family: expect.any(String),
61+
manufacturer: expect.any(String),
62+
model: expect.any(String),
63+
simulator: expect.any(Boolean),
64+
}),
65+
}),
66+
}),
67+
);
68+
});
69+
70+
it('contains app context', async () => {
71+
const item = getItemOfTypeFrom<EventItem>(envelope, 'event');
72+
73+
expect(item?.[1]).toEqual(
74+
expect.objectContaining({
75+
contexts: expect.objectContaining({
76+
app: expect.objectContaining({
77+
app_identifier: expect.any(String),
78+
app_name: expect.any(String),
79+
app_version: expect.any(String),
80+
}),
81+
}),
82+
}),
83+
);
84+
});
85+
86+
it('SDK initialized from JavaScript (auto init)', async () => {
87+
const item = getItemOfTypeFrom<EventItem>(envelope, 'event');
88+
89+
// Verify that native SDK was NOT initialized before JS
90+
// When auto init, the SDK is initialized from JavaScript
91+
expect(item?.[1]).toEqual(
92+
expect.objectContaining({
93+
sdk: expect.objectContaining({
94+
name: 'sentry.javascript.react-native',
95+
}),
96+
}),
97+
);
98+
});
99+
});

samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.ts renamed to samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.manual.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import { getItemOfTypeFrom } from '../../utils/event';
99
import { maestro } from '../../utils/maestro';
1010

11-
describe('Capture message', () => {
11+
describe('Capture message (manual native init)', () => {
1212
let sentryServer = createSentryServer();
1313

1414
let envelope: Envelope;

samples/react-native/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
"build-android-release-legacy": "scripts/build-android-release-legacy.sh",
1111
"build-android-debug": "scripts/build-android-debug.sh",
1212
"build-android-debug-legacy": "scripts/build-android-debug-legacy.sh",
13+
"build-android-debug-auto": "scripts/build-android-debug-auto.sh",
14+
"build-android-debug-manual": "scripts/build-android-debug-manual.sh",
1315
"build-ios-release": "scripts/build-ios-release.sh",
1416
"build-ios-debug": "scripts/build-ios-debug.sh",
1517
"test": "jest",
@@ -18,6 +20,7 @@
1820
"set-test-dsn-android": "scripts/set-dsn-aos.mjs",
1921
"set-test-dsn-ios": "scripts/set-dsn-ios.mjs",
2022
"test-android-manual": "scripts/test-android-manual.sh",
23+
"test-android-auto": "scripts/test-android-auto.sh",
2124
"test-ios-manual": "scripts/test-ios-manual.sh",
2225
"test-ios-auto": "scripts/test-ios-auto.sh",
2326
"lint": "npx eslint . --ext .js,.jsx,.ts,.tsx",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/bin/bash
2+
3+
# Exit on error
4+
set -e
5+
6+
thisFilePath=$(dirname "$0")
7+
8+
export RN_ARCHITECTURE="new"
9+
export CONFIG="debug"
10+
export SENTRY_DISABLE_NATIVE_START="true"
11+
12+
echo "Building Android with SENTRY_DISABLE_NATIVE_START=${SENTRY_DISABLE_NATIVE_START}"
13+
echo "This build will initialize Sentry from JavaScript (auto init)"
14+
15+
"${thisFilePath}/build-android.sh"
16+
17+
# Rename the output APK to distinguish it from manual build
18+
cd "${thisFilePath}/.."
19+
if [ -f "app.apk" ]; then
20+
mv app.apk app-auto.apk
21+
echo "Build complete: app-auto.apk"
22+
fi

0 commit comments

Comments
 (0)