From 40f0d2f3c03a2629d5f8848c8d5fd4ecde90bbf3 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 27 Jan 2026 10:35:09 +0100 Subject: [PATCH 1/5] 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 --- .../reactnative/sample/MainApplication.kt | 11 ++ .../reactnative/sample/SamplePackage.java | 32 +++++ samples/react-native/e2e/README.md | 136 ++++++++++++++++++ .../e2e/jest.config.android.auto.js | 13 ++ .../react-native/e2e/setup.android.auto.ts | 7 + ...aptureAppStartCrash.test.android.manual.ts | 115 +++++++++++++++ ...ptureAppStartCrash.test.android.manual.yml | 28 ++++ .../captureMessage.test.android.auto.ts | 99 +++++++++++++ ... => captureMessage.test.android.manual.ts} | 2 +- samples/react-native/package.json | 3 + .../scripts/build-android-debug-auto.sh | 22 +++ .../scripts/build-android-debug-manual.sh | 22 +++ .../react-native/scripts/test-android-auto.sh | 27 ++++ .../react-native/src/Screens/ErrorsScreen.tsx | 26 +++- 14 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 samples/react-native/e2e/README.md create mode 100644 samples/react-native/e2e/jest.config.android.auto.js create mode 100644 samples/react-native/e2e/setup.android.auto.ts create mode 100644 samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.ts create mode 100644 samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.yml create mode 100644 samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.auto.ts rename samples/react-native/e2e/tests/captureMessage/{captureMessage.test.android.ts => captureMessage.test.android.manual.ts} (98%) create mode 100755 samples/react-native/scripts/build-android-debug-auto.sh create mode 100755 samples/react-native/scripts/build-android-debug-manual.sh create mode 100755 samples/react-native/scripts/test-android-auto.sh diff --git a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt index 5b5ac47444..bbb129f7c8 100644 --- a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt +++ b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt @@ -41,10 +41,21 @@ class MainApplication : RNSentrySDK.init(this) } + // Check for crash-on-start intent for testing + if (shouldCrashOnStart()) { + throw RuntimeException("This was intentional test crash before JS started.") + } + SoLoader.init(this, OpenSourceMergedSoMapping) if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { // If you opted-in for the New Architecture, we load the native entry point for this app. load() } } + + private fun shouldCrashOnStart(): Boolean { + // Check if crash flag file exists (for E2E testing) + val crashFile = getFileStreamPath(".sentry_crash_on_start") + return crashFile.exists() + } } diff --git a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/SamplePackage.java b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/SamplePackage.java index 8dfbd44c8b..434bc8927a 100644 --- a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/SamplePackage.java +++ b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/SamplePackage.java @@ -77,6 +77,38 @@ private void crashNow() { } }); + modules.add( + new ReactContextBaseJavaModule(reactContext) { + @Override + public String getName() { + return "TestControlModule"; + } + + @ReactMethod + public void enableCrashOnStart(Promise promise) { + try { + // Create flag file to trigger crash on next app start + getReactApplicationContext() + .openFileOutput(".sentry_crash_on_start", ReactApplicationContext.MODE_PRIVATE) + .close(); + promise.resolve(true); + } catch (Exception e) { + promise.reject("ERROR", "Failed to enable crash on start", e); + } + } + + @ReactMethod + public void disableCrashOnStart(Promise promise) { + try { + // Delete flag file + getReactApplicationContext().deleteFile(".sentry_crash_on_start"); + promise.resolve(true); + } catch (Exception e) { + promise.reject("ERROR", "Failed to disable crash on start", e); + } + } + }); + return modules; } } diff --git a/samples/react-native/e2e/README.md b/samples/react-native/e2e/README.md new file mode 100644 index 0000000000..90f51b995b --- /dev/null +++ b/samples/react-native/e2e/README.md @@ -0,0 +1,136 @@ +# E2E Testing Guide + +This directory contains end-to-end tests for the React Native sample app, testing both **manual native init** and **auto init from JS** modes. + +## Test Modes + +### Manual Native Init Mode +Native SDK is initialized **before** JavaScript loads, allowing capture of app start crashes. +- **iOS**: Native init via `RNSentrySDK.start()` in AppDelegate +- **Android**: Native init via `RNSentrySDK.init(this)` in MainApplication + +### Auto Init from JS Mode +SDK is initialized **from JavaScript** after app loads (traditional behavior). +- **iOS**: Native init is skipped via launch argument `sentryDisableNativeStart` +- **Android**: Native init is disabled at build time via `SENTRY_DISABLE_NATIVE_START=true` + +## Running Tests + +### Android + +#### Manual Native Init Tests +```bash +# Build with native init enabled +yarn build-android-debug-manual + +# Run manual mode tests +yarn test-android-manual +``` + +#### Auto Init from JS Tests +```bash +# Build with native init disabled +yarn build-android-debug-auto + +# Run auto mode tests +yarn test-android-auto +``` + +### iOS + +#### Manual Native Init Tests +```bash +# Build +yarn build-ios-debug + +# Run manual mode tests +yarn test-ios-manual +``` + +#### Auto Init from JS Tests +```bash +# Build (same build works for both modes) +yarn build-ios-debug + +# Run auto mode tests (uses launch argument to disable native init) +yarn test-ios-auto +``` + +## Test Structure + +``` +e2e/ +├── jest.config.{platform}.{mode}.js # Test configurations +├── setup.{platform}.{mode}.ts # Test setup files +└── tests/ + ├── captureMessage/ # Basic message capture tests + │ ├── *.test.{platform}.{mode}.ts + │ └── *.test.yml # Maestro flows + ├── captureAppStartCrash/ # App start crash tests (manual mode only) + │ ├── *.test.{platform}.manual.ts + │ └── *.test.{platform}.manual.yml + └── ... +``` + +## Platform Differences + +### iOS +- Uses **launch arguments** to control native init at runtime +- Same build can test both modes +- Launch argument: `sentryDisableNativeStart: true/false` + +### Android +- Uses **build configuration** to control native init at compile time +- Requires separate builds for each mode +- Build config: `SENTRY_DISABLE_NATIVE_START=true/false` +- Environment variable set by build scripts + +## Adding New Tests + +### Dual-Mode Tests (runs in both auto and manual) +1. Create test file: `myTest.test.{platform}.{mode}.ts` +2. Create Maestro flow: `myTest.test.yml` (Android) or `myTest.test.{platform}.{mode}.yml` (iOS) +3. Test should work regardless of init mode + +### Manual-Only Tests (app start crashes) +1. Create test file: `myTest.test.{platform}.manual.ts` +2. These tests verify native-only features before JS loads +3. Cannot test in auto mode (JS not loaded yet) + +## App Start Crash Testing + +### Android +Uses a flag file mechanism: +1. Call `TestControlModule.enableCrashOnStart()` from JS +2. Restart app → native crash before JS loads +3. Restart again → crash event is sent +4. Call `TestControlModule.disableCrashOnStart()` to clean up + +### iOS +Uses launch arguments: +1. Launch with `sentryCrashOnStart: true` +2. App crashes in `application:didFinishLaunchingWithOptions` +3. Restart → crash event is sent + +## Debugging + +### View test output +```bash +# Android +adb logcat | grep -i sentry + +# iOS +xcrun simctl spawn booted log stream --predicate 'processImagePath contains "sentryreactnativesample"' +``` + +### Manual testing +```bash +# Android - Install specific build +adb install -r app-manual.apk # or app-auto.apk +adb shell am start -n io.sentry.reactnative.sample/.MainActivity + +# iOS - Use Xcode or simulator +open -a Simulator +xcrun simctl install booted sentryreactnativesample.app +xcrun simctl launch booted io.sentry.reactnative.sample +``` diff --git a/samples/react-native/e2e/jest.config.android.auto.js b/samples/react-native/e2e/jest.config.android.auto.js new file mode 100644 index 0000000000..db4de3a8fd --- /dev/null +++ b/samples/react-native/e2e/jest.config.android.auto.js @@ -0,0 +1,13 @@ +const path = require('path'); +const baseConfig = require('./jest.config.base'); + +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...baseConfig, + globalSetup: path.resolve(__dirname, 'setup.android.auto.ts'), + testMatch: [ + ...baseConfig.testMatch, + '/e2e/**/*.test.android.ts', + '/e2e/**/*.test.android.auto.ts', + ], +}; diff --git a/samples/react-native/e2e/setup.android.auto.ts b/samples/react-native/e2e/setup.android.auto.ts new file mode 100644 index 0000000000..4a840f16a0 --- /dev/null +++ b/samples/react-native/e2e/setup.android.auto.ts @@ -0,0 +1,7 @@ +import { setAutoInitTest } from './utils/environment'; + +function setupAuto() { + setAutoInitTest(); +} + +export default setupAuto; diff --git a/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.ts b/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.ts new file mode 100644 index 0000000000..7db053801f --- /dev/null +++ b/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.ts @@ -0,0 +1,115 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { Envelope, EventItem } from '@sentry/core'; + +import { + createSentryServer, + containingEvent, +} from '../../utils/mockedSentryServer'; +import { getItemOfTypeFrom } from '../../utils/event'; +import { maestro } from '../../utils/maestro'; + +describe('Capture app start crash (Android)', () => { + let sentryServer = createSentryServer(); + + let envelope: Envelope; + + beforeAll(async () => { + await sentryServer.start(); + + const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + + await maestro('tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.yml'); + + envelope = await envelopePromise; + }, 300000); // 5 minutes timeout for crash handling + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains sdk metadata', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item).toEqual([ + { + content_type: 'application/json', + length: expect.any(Number), + type: 'event', + }, + expect.objectContaining({ + platform: 'java', + sdk: expect.objectContaining({ + name: 'sentry.java.android.react-native', + packages: expect.arrayContaining([ + expect.objectContaining({ + name: 'maven:io.sentry:sentry-android-core', + }), + expect.objectContaining({ + name: 'npm:@sentry/react-native', + }), + ]), + }), + }), + ]); + }); + + it('captures app start crash exception', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + exception: expect.objectContaining({ + values: expect.arrayContaining([ + expect.objectContaining({ + type: 'RuntimeException', + value: 'This was intentional test crash before JS started.', + mechanism: expect.objectContaining({ + handled: false, + type: 'UncaughtExceptionHandler', + }), + }), + ]), + }), + }), + ); + }); + + it('crash happened before JS was loaded', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + // Verify this is a native crash, not from JavaScript + expect(item?.[1]).toEqual( + expect.objectContaining({ + platform: 'java', + }), + ); + + // Should not have JavaScript context since JS wasn't loaded yet + expect(item?.[1]?.contexts?.react_native_context).toBeUndefined(); + }); + + it('contains device and app context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + device: expect.objectContaining({ + brand: expect.any(String), + manufacturer: expect.any(String), + model: expect.any(String), + }), + app: expect.objectContaining({ + app_identifier: 'io.sentry.reactnative.sample', + app_name: expect.any(String), + app_version: expect.any(String), + }), + os: expect.objectContaining({ + name: 'Android', + version: expect.any(String), + }), + }), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.yml b/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.yml new file mode 100644 index 0000000000..c8fec7a18e --- /dev/null +++ b/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.yml @@ -0,0 +1,28 @@ +appId: io.sentry.reactnative.sample +--- +# First launch: Enable crash flag and exit gracefully +- launchApp: + clearState: true + stopApp: true + +- tapOn: "Errors" +- tapOn: "Enable Crash on Start" +- stopApp + +# Second launch: App will crash on start due to flag file +- launchApp: + clearState: false + stopApp: false + +# Third launch: Crash event is sent before crashing again +- launchApp: + clearState: false + stopApp: false + +# Fourth launch: Disable the crash flag so app can run normally +- launchApp: + clearState: false + stopApp: false + +- tapOn: "Errors" +- tapOn: "Disable Crash on Start" diff --git a/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.auto.ts b/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.auto.ts new file mode 100644 index 0000000000..2c7bfa0809 --- /dev/null +++ b/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.auto.ts @@ -0,0 +1,99 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { Envelope, EventItem } from '@sentry/core'; + +import { + createSentryServer, + containingEventWithAndroidMessage, +} from '../../utils/mockedSentryServer'; +import { getItemOfTypeFrom } from '../../utils/event'; +import { maestro } from '../../utils/maestro'; + +describe('Capture message (auto init from JS)', () => { + let sentryServer = createSentryServer(); + + let envelope: Envelope; + + beforeAll(async () => { + await sentryServer.start(); + + const envelopePromise = sentryServer.waitForEnvelope( + containingEventWithAndroidMessage('Captured message'), + ); + + await maestro('tests/captureMessage/captureMessage.test.yml'); + + envelope = await envelopePromise; + }, 240000); // 240 seconds timeout + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains message event', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item).toEqual([ + { + content_type: 'application/json', + length: expect.any(Number), + type: 'event', + }, + expect.objectContaining({ + level: 'info', + message: { + message: 'Captured message', + }, + platform: 'javascript', + }), + ]); + }); + + it('contains device context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + device: expect.objectContaining({ + battery_level: expect.any(Number), + brand: expect.any(String), + family: expect.any(String), + manufacturer: expect.any(String), + model: expect.any(String), + simulator: expect.any(Boolean), + }), + }), + }), + ); + }); + + it('contains app context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + app: expect.objectContaining({ + app_identifier: expect.any(String), + app_name: expect.any(String), + app_version: expect.any(String), + }), + }), + }), + ); + }); + + it('SDK initialized from JavaScript (auto init)', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + // Verify that native SDK was NOT initialized before JS + // When auto init, the SDK is initialized from JavaScript + expect(item?.[1]).toEqual( + expect.objectContaining({ + sdk: expect.objectContaining({ + name: 'sentry.javascript.react-native', + }), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.ts b/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.manual.ts similarity index 98% rename from samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.ts rename to samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.manual.ts index 9703e0a7a3..95c1bf3c6d 100644 --- a/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.ts +++ b/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.manual.ts @@ -8,7 +8,7 @@ import { import { getItemOfTypeFrom } from '../../utils/event'; import { maestro } from '../../utils/maestro'; -describe('Capture message', () => { +describe('Capture message (manual native init)', () => { let sentryServer = createSentryServer(); let envelope: Envelope; diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 3df55f0d25..9ba36e4b8c 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -10,6 +10,8 @@ "build-android-release-legacy": "scripts/build-android-release-legacy.sh", "build-android-debug": "scripts/build-android-debug.sh", "build-android-debug-legacy": "scripts/build-android-debug-legacy.sh", + "build-android-debug-auto": "scripts/build-android-debug-auto.sh", + "build-android-debug-manual": "scripts/build-android-debug-manual.sh", "build-ios-release": "scripts/build-ios-release.sh", "build-ios-debug": "scripts/build-ios-debug.sh", "test": "jest", @@ -18,6 +20,7 @@ "set-test-dsn-android": "scripts/set-dsn-aos.mjs", "set-test-dsn-ios": "scripts/set-dsn-ios.mjs", "test-android-manual": "scripts/test-android-manual.sh", + "test-android-auto": "scripts/test-android-auto.sh", "test-ios-manual": "scripts/test-ios-manual.sh", "test-ios-auto": "scripts/test-ios-auto.sh", "lint": "npx eslint . --ext .js,.jsx,.ts,.tsx", diff --git a/samples/react-native/scripts/build-android-debug-auto.sh b/samples/react-native/scripts/build-android-debug-auto.sh new file mode 100755 index 0000000000..a4e50a4d1f --- /dev/null +++ b/samples/react-native/scripts/build-android-debug-auto.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="new" +export CONFIG="debug" +export SENTRY_DISABLE_NATIVE_START="true" + +echo "Building Android with SENTRY_DISABLE_NATIVE_START=${SENTRY_DISABLE_NATIVE_START}" +echo "This build will initialize Sentry from JavaScript (auto init)" + +"${thisFilePath}/build-android.sh" + +# Rename the output APK to distinguish it from manual build +cd "${thisFilePath}/.." +if [ -f "app.apk" ]; then + mv app.apk app-auto.apk + echo "Build complete: app-auto.apk" +fi diff --git a/samples/react-native/scripts/build-android-debug-manual.sh b/samples/react-native/scripts/build-android-debug-manual.sh new file mode 100755 index 0000000000..7bb4754496 --- /dev/null +++ b/samples/react-native/scripts/build-android-debug-manual.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="new" +export CONFIG="debug" +export SENTRY_DISABLE_NATIVE_START="false" + +echo "Building Android with SENTRY_DISABLE_NATIVE_START=${SENTRY_DISABLE_NATIVE_START}" +echo "This build will initialize Sentry natively before JS (manual init)" + +"${thisFilePath}/build-android.sh" + +# Rename the output APK to distinguish it from auto build +cd "${thisFilePath}/.." +if [ -f "app.apk" ]; then + mv app.apk app-manual.apk + echo "Build complete: app-manual.apk" +fi diff --git a/samples/react-native/scripts/test-android-auto.sh b/samples/react-native/scripts/test-android-auto.sh new file mode 100755 index 0000000000..5a27fe3457 --- /dev/null +++ b/samples/react-native/scripts/test-android-auto.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e # exit on error + +# Get current directory +thisFileDirPath=$(dirname "$0") +reactProjectRootPath="$(cd "$thisFileDirPath/.." && pwd)" + +maybeApkPath=$(find "${reactProjectRootPath}" -maxdepth 1 -name "*-auto.apk") + +# Check if any APK files exist +apk_count=$(echo "$maybeApkPath" | wc -l) + +if [ -n "$maybeApkPath" ] && [ $apk_count -eq 1 ]; then + # Force install single APK using adb + apk_file="${maybeApkPath}" + echo "Installing $apk_file..." + adb install -r "$apk_file" +elif [ $apk_count -gt 1 ]; then + echo "Error: Multiple APK files found. Expected only one APK file." + exit 1 +else + echo "No APK files found, continuing without install" +fi + +# Run the tests +npx jest --config e2e/jest.config.android.auto.js diff --git a/samples/react-native/src/Screens/ErrorsScreen.tsx b/samples/react-native/src/Screens/ErrorsScreen.tsx index a03022a351..d1b2052ed1 100644 --- a/samples/react-native/src/Screens/ErrorsScreen.tsx +++ b/samples/react-native/src/Screens/ErrorsScreen.tsx @@ -22,7 +22,7 @@ import { setScopeProperties } from '../setScopeProperties'; import { TimeToFullDisplay } from '../utils'; import type { Event as SentryEvent } from '@sentry/core'; -const { AssetsModule, CppModule, CrashModule } = NativeModules; +const { AssetsModule, CppModule, CrashModule, TestControlModule } = NativeModules; interface Props { navigation: StackNavigationProp; @@ -207,6 +207,30 @@ const ErrorsScreen = (_props: Props) => { }); }} /> +